feat: create entity page header extension

Signed-off-by: Camila Belo <camilaibs@gmail.com>
This commit is contained in:
Camila Belo
2025-02-14 22:53:59 +01:00
parent 3a0ff39718
commit 247a40b9a5
14 changed files with 1199 additions and 603 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog': minor
---
Now multiple entity page headers can be configured based on an entity filter.
+96
View File
@@ -0,0 +1,96 @@
---
'@backstage/plugin-catalog-react': minor
---
Introduce a new `EntityHeaderBlueprint` that allows you to customize the default header and also have different headers depending on an entity filter.
### Usage examples
Customizing the default header to render more title actions:
```jsx
import { EntityHeaderBlueprint } from '@backstage/plugin-catalog-react/alpha';
// ...
function CopyEntityNameToClipboard() {
const { entity } = useEntity();
const alertApi = useApi(alertApiRef);
const handleClick = useCallback(() => {
if (!entity) return;
window.navigator.clipboard
.writeText(entity.metadata.name)
.then(() =>
alertApi.post({ message: 'Entity name copied to clipboard!' }),
);
}, [entity, alertApi]);
return (
<Tooltip title="Copy to clipboard">
<IconButton onClick={handleClick}>
<FileCopyIcon htmlColor="#fff" />
</IconButton>
</Tooltip>
);
}
EntityHeaderBlueprint.make({
name: 'my-default-header',
params: {
// The `FavoriteEntity` icon button is added by default
// You can also completely override the default title
// title: <MyTitleComponent />
title: { actions: [<CopyEntityNameToClipboard />] },
// A subtitle element is also supported.
// subtitle: <MySubtitleComponent />
},
});
```
Setting up a completely different default header component:
```jsx
import { EntityHeaderBlueprint } from '@backstage/plugin-catalog-react/alpha';
EntityHeaderBlueprint.make({
name: 'my-default-header',
params: {
loader: () => import('./MyDefaultHeader').then(m => <m.MyDefaultHeader />),
},
});
```
Use a different header for entities of type template:
```jsx
import { EntityHeaderBlueprint } from '@backstage/plugin-catalog-react/alpha';
import { MyTemplateHeader } from './MyTemplateHeader';
EntityHeaderBlueprint.make({
name: 'my-template-header',
params: {
defaultFilter: 'kind:template',
loader: () => import('./MyTemplateHeader').then(m => <m.MyTemplateHeader />),
});
```
Disabling a header via configuration:
```yaml
# app-config.yaml
app:
extensions:
- entity-header:app/my-template-header: false
```
Changing a header default filter via configuration:
```yaml
# app-config.yaml
app:
extensions:
- entity-header:app/my-template-header:
config:
# Using this custom header with components instead
filter: 'kind:component'
```
+97
View File
@@ -14,6 +14,7 @@ import { ExtensionDefinition } from '@backstage/frontend-plugin-api';
import { JsonValue } from '@backstage/types';
import { JSX as JSX_2 } from 'react';
import { default as React_2 } from 'react';
import { ReactNode } from 'react';
import { ResourcePermission } from '@backstage/plugin-permission-common';
import { RouteRef } from '@backstage/frontend-plugin-api';
import { TranslationRef } from '@backstage/core-plugin-api/alpha';
@@ -321,6 +322,102 @@ export interface EntityContentLayoutProps {
}>;
}
// @alpha (undocumented)
export const EntityHeaderBlueprint: ExtensionBlueprint<{
kind: 'entity-header';
name: undefined;
params:
| {
defaultFilter?: string | ((entity: Entity) => boolean) | undefined;
loader: () => Promise<JSX.Element>;
}
| {
defaultFilter?: string | ((entity: Entity) => boolean) | undefined;
title?:
| ReactNode
| {
actions: ReactNode[];
};
subtitle?: ReactNode;
};
output:
| ConfigurableExtensionDataRef<
(entity: Entity) => boolean,
'catalog.entity-filter-function',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
string,
'catalog.entity-filter-expression',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
JSX_2.Element,
'core.reactElement',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
| ReactNode
| {
actions: ReactNode[];
},
'entity-header.titleActions',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
ReactNode,
'entity-header.subtitle',
{
optional: true;
}
>;
inputs: {};
config: {
filter: string | undefined;
};
configInput: {
filter?: string | undefined;
};
dataRefs: {
filterFunction: ConfigurableExtensionDataRef<
(entity: Entity) => boolean,
'catalog.entity-filter-function',
{}
>;
filterExpression: ConfigurableExtensionDataRef<
string,
'catalog.entity-filter-expression',
{}
>;
element: ConfigurableExtensionDataRef<
JSX_2.Element,
'core.reactElement',
{}
>;
title: ConfigurableExtensionDataRef<
| ReactNode
| {
actions: ReactNode[];
},
'entity-header.titleActions',
{}
>;
subtitle: ConfigurableExtensionDataRef<
ReactNode,
'entity-header.subtitle',
{}
>;
};
}>;
// @alpha (undocumented)
export type EntityPredicate =
| EntityPredicateExpression
@@ -0,0 +1,101 @@
/*
* Copyright 2025 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 { ReactNode } from 'react';
import {
createExtensionBlueprint,
coreExtensionData,
createExtensionDataRef,
ExtensionBoundary,
} from '@backstage/frontend-plugin-api';
import {
entityFilterExpressionDataRef,
entityFilterFunctionDataRef,
} from './extensionData';
const entityHeaderTitleActionsDataRef = createExtensionDataRef<
ReactNode | { actions: ReactNode[] }
>().with({ id: 'entity-header.titleActions' });
const entityHeaderSubtitleDataRef = createExtensionDataRef<ReactNode>().with({
id: 'entity-header.subtitle',
});
/** @alpha */
export const EntityHeaderBlueprint = createExtensionBlueprint({
kind: 'entity-header',
attachTo: { id: 'page:catalog/entity', input: 'headers' },
output: [
entityFilterFunctionDataRef.optional(),
entityFilterExpressionDataRef.optional(),
coreExtensionData.reactElement.optional(),
entityHeaderTitleActionsDataRef.optional(),
entityHeaderSubtitleDataRef.optional(),
],
dataRefs: {
filterFunction: entityFilterFunctionDataRef,
filterExpression: entityFilterExpressionDataRef,
element: coreExtensionData.reactElement,
title: entityHeaderTitleActionsDataRef,
subtitle: entityHeaderSubtitleDataRef,
},
config: {
schema: {
filter: z => z.string().optional(),
},
},
*factory(
params:
| {
defaultFilter?:
| typeof entityFilterFunctionDataRef.T
| typeof entityFilterExpressionDataRef.T;
loader: () => Promise<JSX.Element>;
}
| {
defaultFilter?:
| typeof entityFilterFunctionDataRef.T
| typeof entityFilterExpressionDataRef.T;
title?: ReactNode | { actions: ReactNode[] };
subtitle?: ReactNode;
},
{ config, node },
) {
const { defaultFilter } = params;
if (config.filter) {
yield entityFilterExpressionDataRef(config.filter);
} else if (typeof defaultFilter === 'string') {
yield entityFilterExpressionDataRef(defaultFilter);
} else if (typeof defaultFilter === 'function') {
yield entityFilterFunctionDataRef(defaultFilter);
}
if ('loader' in params) {
yield coreExtensionData.reactElement(
ExtensionBoundary.lazy(node, params.loader),
);
}
if ('title' in params && params.title) {
yield entityHeaderTitleActionsDataRef(params.title);
}
if ('subtitle' in params && params.subtitle) {
yield entityHeaderSubtitleDataRef(params.subtitle);
}
},
});
@@ -19,5 +19,6 @@ export {
EntityContentLayoutBlueprint,
type EntityContentLayoutProps,
} from './EntityContentLayoutBlueprint';
export { EntityHeaderBlueprint } from './EntityHeaderBlueprint';
export { defaultEntityContentGroups } from './extensionData';
export type { EntityCardType } from './extensionData';
+45
View File
@@ -20,6 +20,7 @@ import { ExternalRouteRef } from '@backstage/frontend-plugin-api';
import { FrontendPlugin } from '@backstage/frontend-plugin-api';
import { IconComponent } from '@backstage/core-plugin-api';
import { JSX as JSX_2 } from 'react';
import { ReactNode } from 'react';
import { RouteRef } from '@backstage/frontend-plugin-api';
import { SearchResultItemExtensionComponent } from '@backstage/plugin-search-react/alpha';
import { SearchResultItemExtensionPredicate } from '@backstage/plugin-search-react/alpha';
@@ -972,6 +973,50 @@ const _default: FrontendPlugin<
}
>;
inputs: {
headers: ExtensionInput<
| ConfigurableExtensionDataRef<
(entity: Entity) => boolean,
'catalog.entity-filter-function',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
string,
'catalog.entity-filter-expression',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
JSX_2.Element,
'core.reactElement',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
| ReactNode
| {
actions: ReactNode[];
},
'entity-header.titleActions',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
ReactNode,
'entity-header.subtitle',
{
optional: true;
}
>,
{
singleton: false;
optional: false;
}
>;
contents: ExtensionInput<
| ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ConfigurableExtensionDataRef<string, 'core.routing.path', {}>
@@ -0,0 +1,321 @@
/*
* Copyright 2025 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 React, {
useState,
useCallback,
useEffect,
ComponentProps,
ReactNode,
Fragment,
} from 'react';
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
import useAsync from 'react-use/esm/useAsync';
import { makeStyles } from '@material-ui/core/styles';
import Box from '@material-ui/core/Box';
import { Header, Breadcrumbs } from '@backstage/core-components';
import {
useApi,
useRouteRef,
useRouteRefParams,
} from '@backstage/core-plugin-api';
import { IconComponent } from '@backstage/frontend-plugin-api';
import {
Entity,
EntityRelation,
DEFAULT_NAMESPACE,
} from '@backstage/catalog-model';
import {
useAsyncEntity,
entityRouteRef,
catalogApiRef,
EntityRefLink,
InspectEntityDialog,
UnregisterEntityDialog,
EntityDisplayName,
FavoriteEntity,
} from '@backstage/plugin-catalog-react';
import { EntityLabels } from '../EntityLabels';
import { EntityContextMenu } from '../../../components/EntityContextMenu';
import { rootRouteRef, unregisterRedirectRouteRef } from '../../../routes';
function headerProps(
paramKind: string | undefined,
paramNamespace: string | undefined,
paramName: string | undefined,
entity: Entity | undefined,
): { headerTitle: string; headerType: string } {
const kind = paramKind ?? entity?.kind ?? '';
const namespace = paramNamespace ?? entity?.metadata.namespace ?? '';
const name =
entity?.metadata.title ?? paramName ?? entity?.metadata.name ?? '';
return {
headerTitle: `${name}${
namespace && namespace !== DEFAULT_NAMESPACE ? ` in ${namespace}` : ''
}`,
headerType: (() => {
let t = kind.toLocaleLowerCase('en-US');
if (entity && entity.spec && 'type' in entity.spec) {
t += ' — ';
t += (entity.spec as { type: string }).type.toLocaleLowerCase('en-US');
}
return t;
})(),
};
}
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;
}
function isDefaultHeaderProps(
title: ReactNode | { actions: ReactNode[] },
): title is { actions: ReactNode[] } {
return title !== null && typeof title === 'object' && 'actions' in title;
}
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',
},
},
}));
function EntityHeaderTitle(props: { actions?: ReactNode[] }) {
const { actions } = props;
const { entity } = useAsyncEntity();
const { kind, namespace, name } = useRouteRefParams(entityRouteRef);
const { headerTitle: title } = headerProps(kind, namespace, name, entity);
return (
<Box display="inline-flex" alignItems="center" height="1em" maxWidth="100%">
<Box
component="span"
textOverflow="ellipsis"
whiteSpace="nowrap"
overflow="hidden"
>
{entity ? <EntityDisplayName entityRef={entity} hideIcon /> : title}
</Box>
{entity && <FavoriteEntity entity={entity} />}
{actions?.map((action, index) => (
<Fragment key={index}>{action}</Fragment>
))}
</Box>
);
}
function EntityHeaderSubtitle(props: { parentEntityRelations?: string[] }) {
const { parentEntityRelations } = props;
const classes = useStyles();
const { entity } = useAsyncEntity();
const { name } = useRouteRefParams(entityRouteRef);
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, catalogApi]);
return parentEntity ? (
<Breadcrumbs separator=">" className={classes.breadcrumbs}>
{ancestorEntity && (
<EntityRefLink entityRef={ancestorEntity.targetRef} disableTooltip />
)}
<EntityRefLink entityRef={parentEntity.targetRef} disableTooltip />
{name}
</Breadcrumbs>
) : null;
}
/** @alpha */
export function EntityHeader(props: {
// NOTE(freben): Intentionally not exported at this point, since it's part of
// the unstable extra context menu items concept below
UNSTABLE_extraContextMenuItems?: {
title: string;
Icon: IconComponent;
onClick: () => void;
}[];
// NOTE(blam): Intentionally not exported at this point, since it's part of
// unstable context menu option, eg: disable the unregister entity menu
UNSTABLE_contextMenuOptions?: {
disableUnregister: boolean | 'visible' | 'hidden' | 'disable';
};
/**
* 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[];
title?: ReactNode | { actions: ReactNode[] };
subtitle?: ReactNode;
}) {
const {
UNSTABLE_extraContextMenuItems,
UNSTABLE_contextMenuOptions,
parentEntityRelations,
title = { actions: [] },
subtitle,
} = props;
const { entity } = useAsyncEntity();
const { kind, namespace, name } = useRouteRefParams(entityRouteRef);
const { headerTitle: entityFallbackText, headerType: type } = headerProps(
kind,
namespace,
name,
entity,
);
const location = useLocation();
const navigate = useNavigate();
const catalogRoute = useRouteRef(rootRouteRef);
const unregisterRedirectRoute = useRouteRef(unregisterRedirectRouteRef);
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
const openUnregisterEntityDialog = useCallback(
() => setConfirmationDialogOpen(true),
[setConfirmationDialogOpen],
);
const closeUnregisterEntityDialog = useCallback(
() => setConfirmationDialogOpen(false),
[setConfirmationDialogOpen],
);
const cleanUpAfterUnregiterConfirmation = useCallback(async () => {
setConfirmationDialogOpen(false);
navigate(
unregisterRedirectRoute ? unregisterRedirectRoute() : catalogRoute(),
);
}, [
navigate,
catalogRoute,
unregisterRedirectRoute,
setConfirmationDialogOpen,
]);
// Make sure to close the dialog if the user clicks links in it that navigate
// to another entity.
useEffect(() => {
setConfirmationDialogOpen(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.pathname]);
const [searchParams, setSearchParams] = useSearchParams();
const selectedInspectEntityDialogTab = searchParams.get('inspect');
const setInspectEntityDialogTab = useCallback(
(newTab: string) => setSearchParams(`inspect=${newTab}`),
[setSearchParams],
);
const openInspectEntityDialog = useCallback(
() => setSearchParams('inspect'),
[setSearchParams],
);
const closeInspectEntityDialog = useCallback(
() => setSearchParams(),
[setSearchParams],
);
const inspectDialogOpen = typeof selectedInspectEntityDialogTab === 'string';
return (
<Header
pageTitleOverride={entityFallbackText}
type={type}
title={
isDefaultHeaderProps(title) ? <EntityHeaderTitle {...title} /> : title
}
subtitle={
subtitle ?? (
<EntityHeaderSubtitle parentEntityRelations={parentEntityRelations} />
)
}
>
{entity && (
<>
<EntityLabels entity={entity} />
<EntityContextMenu
UNSTABLE_extraContextMenuItems={UNSTABLE_extraContextMenuItems}
UNSTABLE_contextMenuOptions={UNSTABLE_contextMenuOptions}
onInspectEntity={openInspectEntityDialog}
onUnregisterEntity={openUnregisterEntityDialog}
/>
<InspectEntityDialog
entity={entity!}
initialTab={
(selectedInspectEntityDialogTab as ComponentProps<
typeof InspectEntityDialog
>['initialTab']) || undefined
}
open={inspectDialogOpen}
onClose={closeInspectEntityDialog}
onSelect={setInspectEntityDialogTab}
/>
<UnregisterEntityDialog
entity={entity!}
open={confirmationDialogOpen}
onClose={closeUnregisterEntityDialog}
onConfirm={cleanUpAfterUnregiterConfirmation}
/>
</>
)}
</Header>
);
}
@@ -0,0 +1,17 @@
/*
* Copyright 2025 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.
*/
export { EntityHeader } from './EntityHeader';
@@ -22,7 +22,7 @@ import {
getEntityRelations,
} from '@backstage/plugin-catalog-react';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { catalogTranslationRef } from '../../../alpha/translation';
import { catalogTranslationRef } from '../../translation';
type EntityLabelsProps = {
entity: Entity;
@@ -0,0 +1,17 @@
/*
* Copyright 2025 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.
*/
export { EntityLabels } from './EntityLabels';
@@ -14,51 +14,32 @@
* limitations under the License.
*/
import React, { ComponentProps, useEffect, useState } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import useAsync from 'react-use/esm/useAsync';
import React, { ComponentProps, ReactNode } from 'react';
import { makeStyles } from '@material-ui/core/styles';
import Alert from '@material-ui/lab/Alert';
import {
attachComponentData,
IconComponent,
useApi,
useElementFilter,
useRouteRef,
useRouteRefParams,
} from '@backstage/core-plugin-api';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import {
Breadcrumbs,
Content,
Header,
Link,
Page,
Progress,
WarningPanel,
} from '@backstage/core-components';
import { Entity } from '@backstage/catalog-model';
import {
DEFAULT_NAMESPACE,
Entity,
EntityRelation,
} from '@backstage/catalog-model';
import {
catalogApiRef,
EntityRefLink,
entityRouteRef,
InspectEntityDialog,
UnregisterEntityDialog,
useAsyncEntity,
} from '@backstage/plugin-catalog-react';
import { catalogTranslationRef } from '../../../alpha/translation';
import { rootRouteRef, unregisterRedirectRouteRef } from '../../../routes';
import { EntityContextMenu } from '../../../components/EntityContextMenu/EntityContextMenu';
import { catalogTranslationRef } from '../../translation';
import { EntityHeader } from '../EntityHeader';
import { EntityTabs } from '../EntityTabs';
import { EntityLabels } from '../EntityLabels/EntityLabels';
import { EntityLayoutTitle } from './EntityLayoutTitle';
export type EntityLayoutRouteProps = {
path: string;
@@ -73,84 +54,17 @@ const Route: (props: EntityLayoutRouteProps) => null = () => null;
attachComponentData(Route, dataKey, true);
attachComponentData(Route, 'core.gatherMountPoints', true); // This causes all mount points that are discovered within this route to use the path of the route itself
function headerProps(
paramKind: string | undefined,
paramNamespace: string | undefined,
paramName: string | undefined,
entity: Entity | undefined,
): { headerTitle: string; headerType: string } {
const kind = paramKind ?? entity?.kind ?? '';
const namespace = paramNamespace ?? entity?.metadata.namespace ?? '';
const name =
entity?.metadata.title ?? paramName ?? entity?.metadata.name ?? '';
return {
headerTitle: `${name}${
namespace && namespace !== DEFAULT_NAMESPACE ? ` in ${namespace}` : ''
}`,
headerType: (() => {
let t = kind.toLocaleLowerCase('en-US');
if (entity && entity.spec && 'type' in entity.spec) {
t += ' — ';
t += (entity.spec as { type: string }).type.toLocaleLowerCase('en-US');
}
return t;
})(),
};
}
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',
},
},
}));
// NOTE(freben): Intentionally not exported at this point, since it's part of
// the unstable extra context menu items concept below
interface ExtraContextMenuItem {
title: string;
Icon: IconComponent;
onClick: () => void;
}
type VisibleType = 'visible' | 'hidden' | 'disable';
// NOTE(blam): Intentionally not exported at this point, since it's part of
// unstable context menu option, eg: disable the unregister entity menu
interface EntityContextMenuOptions {
disableUnregister: boolean | VisibleType;
}
/** @public */
export interface EntityLayoutProps {
UNSTABLE_extraContextMenuItems?: ExtraContextMenuItem[];
UNSTABLE_contextMenuOptions?: EntityContextMenuOptions;
children?: React.ReactNode;
NotFoundComponent?: React.ReactNode;
UNSTABLE_contextMenuOptions?: ComponentProps<
typeof EntityHeader
>['UNSTABLE_contextMenuOptions'];
UNSTABLE_extraContextMenuItems?: ComponentProps<
typeof EntityHeader
>['UNSTABLE_extraContextMenuItems'];
children?: ReactNode;
header?: JSX.Element;
NotFoundComponent?: 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
@@ -186,13 +100,12 @@ export const EntityLayout = (props: EntityLayoutProps) => {
UNSTABLE_extraContextMenuItems,
UNSTABLE_contextMenuOptions,
children,
header,
NotFoundComponent,
parentEntityRelations,
} = props;
const classes = useStyles();
const { kind, namespace, name } = useRouteRefParams(entityRouteRef);
const { kind } = useRouteRefParams(entityRouteRef);
const { entity, loading, error } = useAsyncEntity();
const location = useLocation();
const routes = useElementFilter(
children,
@@ -223,90 +136,17 @@ export const EntityLayout = (props: EntityLayoutProps) => {
[entity],
);
const { headerTitle, headerType } = headerProps(
kind,
namespace,
name,
entity,
);
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const catalogRoute = useRouteRef(rootRouteRef);
const unregisterRedirectRoute = useRouteRef(unregisterRedirectRouteRef);
const { t } = useTranslationRef(catalogTranslationRef);
const cleanUpAfterRemoval = async () => {
setConfirmationDialogOpen(false);
navigate(
unregisterRedirectRoute ? unregisterRedirectRoute() : catalogRoute(),
);
};
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(() => {
setConfirmationDialogOpen(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.pathname]);
const selectedInspectTab = searchParams.get('inspect');
const showInspectTab = typeof selectedInspectTab === 'string';
return (
<Page themeId={entity?.spec?.type?.toString() ?? 'home'}>
<Header
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 && (
<>
<EntityLabels entity={entity} />
<EntityContextMenu
UNSTABLE_extraContextMenuItems={UNSTABLE_extraContextMenuItems}
UNSTABLE_contextMenuOptions={UNSTABLE_contextMenuOptions}
onUnregisterEntity={() => setConfirmationDialogOpen(true)}
onInspectEntity={() => setSearchParams('inspect')}
/>
</>
)}
</Header>
{header ?? (
<EntityHeader
parentEntityRelations={parentEntityRelations}
UNSTABLE_contextMenuOptions={UNSTABLE_contextMenuOptions}
UNSTABLE_extraContextMenuItems={UNSTABLE_extraContextMenuItems}
/>
)}
{loading && <Progress />}
@@ -333,27 +173,6 @@ export const EntityLayout = (props: EntityLayoutProps) => {
)}
</Content>
)}
{showInspectTab && (
<InspectEntityDialog
entity={entity!}
initialTab={
(selectedInspectTab as ComponentProps<
typeof InspectEntityDialog
>['initialTab']) || undefined
}
onSelect={newTab => setSearchParams(`inspect=${newTab}`)}
open
onClose={() => setSearchParams()}
/>
)}
<UnregisterEntityDialog
open={confirmationDialogOpen}
entity={entity!}
onConfirm={cleanUpAfterRemoval}
onClose={() => setConfirmationDialogOpen(false)}
/>
</Page>
);
};
@@ -1,45 +0,0 @@
/*
* Copyright 2025 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 React from 'react';
import Box from '@material-ui/core/Box';
import { Entity } from '@backstage/catalog-model';
import {
EntityDisplayName,
FavoriteEntity,
} from '@backstage/plugin-catalog-react';
type EntityLayoutTitleProps = {
title: string;
entity: Entity | undefined;
};
export function EntityLayoutTitle(props: EntityLayoutTitleProps) {
const { entity, title } = props;
return (
<Box display="inline-flex" alignItems="center" height="1em" maxWidth="100%">
<Box
component="span"
textOverflow="ellipsis"
whiteSpace="nowrap"
overflow="hidden"
>
{entity ? <EntityDisplayName entityRef={entity} hideIcon /> : title}
</Box>
{entity && <FavoriteEntity entity={entity} />}
</Box>
);
}
+444 -352
View File
@@ -23,7 +23,10 @@ import {
TestApiProvider,
} from '@backstage/frontend-test-utils';
import { catalogEntityPage } from './pages';
import { EntityContentBlueprint } from '@backstage/plugin-catalog-react/alpha';
import {
EntityContentBlueprint,
EntityHeaderBlueprint,
} from '@backstage/plugin-catalog-react/alpha';
import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils';
import {
catalogApiRef,
@@ -34,7 +37,7 @@ import {
import { convertLegacyRouteRef } from '@backstage/core-compat-api';
import { rootRouteRef } from '../routes';
describe('Index page', () => {
describe('Entity page', () => {
const entityMock = {
metadata: {
namespace: 'default',
@@ -114,6 +117,7 @@ describe('Index page', () => {
params: {
defaultPath: '/overview',
defaultTitle: 'Overview',
defaultGroup: 'documentation',
loader: async () => <div>Mock Overview content</div>,
},
});
@@ -138,372 +142,460 @@ describe('Index page', () => {
},
});
it('Should render a group as dropdown', async () => {
const tester = createExtensionTester(
Object.assign({ namespace: 'catalog' }, catalogEntityPage),
)
.add(techdocsEntityContent)
.add(apidocsEntityContent);
describe('Entity Page Groups', () => {
it('Should render a group as dropdown', async () => {
const tester = createExtensionTester(
Object.assign({ namespace: 'catalog' }, catalogEntityPage),
)
.add(techdocsEntityContent)
.add(apidocsEntityContent);
await renderInTestApp(
<TestApiProvider
apis={[
[catalogApiRef, mockCatalogApi],
[starredEntitiesApiRef, mockStarredEntitiesApi],
]}
>
{tester.reactElement()}
</TestApiProvider>,
{
config: {
app: {
title: 'Custom app',
},
backend: { baseUrl: 'http://localhost:7000' },
},
mountedRoutes: {
'/catalog': convertLegacyRouteRef(rootRouteRef),
'/catalog/:namespace/:kind/:name':
convertLegacyRouteRef(entityRouteRef),
},
},
);
await waitFor(() =>
expect(
screen.getByRole('tab', { name: /Documentation/ }),
).toBeInTheDocument(),
);
await userEvent.click(screen.getByRole('tab', { name: /Documentation/ }));
await waitFor(() =>
expect(screen.getByRole('button', { name: /TechDocs/ })).toHaveAttribute(
'href',
'/techdocs',
),
);
await waitFor(() =>
expect(screen.getByRole('button', { name: /ApiDocs/ })).toHaveAttribute(
'href',
'/apidocs',
),
);
});
it('Should rename a default group', async () => {
const tester = createExtensionTester(
Object.assign({ namespace: 'catalog' }, catalogEntityPage),
{
config: {
groups: [
{
documentation: { title: 'Docs' },
await renderInTestApp(
<TestApiProvider
apis={[
[catalogApiRef, mockCatalogApi],
[starredEntitiesApiRef, mockStarredEntitiesApi],
]}
>
{tester.reactElement()}
</TestApiProvider>,
{
config: {
app: {
title: 'Custom app',
},
],
},
},
)
.add(techdocsEntityContent)
.add(apidocsEntityContent);
await renderInTestApp(
<TestApiProvider
apis={[
[catalogApiRef, mockCatalogApi],
[starredEntitiesApiRef, mockStarredEntitiesApi],
]}
>
{tester.reactElement()}
</TestApiProvider>,
{
config: {
app: {
title: 'Custom app',
backend: { baseUrl: 'http://localhost:7000' },
},
backend: { baseUrl: 'http://localhost:7000' },
},
mountedRoutes: {
'/catalog': convertLegacyRouteRef(rootRouteRef),
'/catalog/:namespace/:kind/:name':
convertLegacyRouteRef(entityRouteRef),
},
},
);
await waitFor(() =>
expect(screen.queryByRole('tab', { name: /Docs/ })).toBeInTheDocument(),
);
await userEvent.click(screen.getByRole('tab', { name: /Docs/ }));
await waitFor(() =>
expect(screen.getByRole('button', { name: /TechDocs/ })).toHaveAttribute(
'href',
'/techdocs',
),
);
await waitFor(() =>
expect(screen.getByRole('button', { name: /ApiDocs/ })).toHaveAttribute(
'href',
'/apidocs',
),
);
});
it('Should disassociate a content with a default group', async () => {
const tester = createExtensionTester(
Object.assign({ namespace: 'catalog' }, catalogEntityPage),
)
.add(techdocsEntityContent)
.add(apidocsEntityContent, {
config: {
group: false,
},
});
await renderInTestApp(
<TestApiProvider
apis={[
[catalogApiRef, mockCatalogApi],
[starredEntitiesApiRef, mockStarredEntitiesApi],
]}
>
{tester.reactElement()}
</TestApiProvider>,
{
config: {
app: {
title: 'Custom app',
mountedRoutes: {
'/catalog': convertLegacyRouteRef(rootRouteRef),
'/catalog/:namespace/:kind/:name':
convertLegacyRouteRef(entityRouteRef),
},
backend: { baseUrl: 'http://localhost:7000' },
},
mountedRoutes: {
'/catalog': convertLegacyRouteRef(rootRouteRef),
'/catalog/:namespace/:kind/:name':
convertLegacyRouteRef(entityRouteRef),
);
await waitFor(() =>
expect(
screen.getByRole('tab', { name: /Documentation/ }),
).toBeInTheDocument(),
);
await userEvent.click(screen.getByRole('tab', { name: /Documentation/ }));
await waitFor(() =>
expect(
screen.getByRole('button', { name: /TechDocs/ }),
).toHaveAttribute('href', '/techdocs'),
);
await waitFor(() =>
expect(screen.getByRole('button', { name: /ApiDocs/ })).toHaveAttribute(
'href',
'/apidocs',
),
);
});
it('Should rename a default group', async () => {
const tester = createExtensionTester(
Object.assign({ namespace: 'catalog' }, catalogEntityPage),
{
config: {
groups: [
{
documentation: { title: 'Docs' },
},
],
},
},
},
);
)
.add(techdocsEntityContent)
.add(apidocsEntityContent);
await waitFor(() =>
expect(
screen.queryByRole('tab', { name: /Documentation/ }),
).not.toBeInTheDocument(),
);
await waitFor(() =>
expect(screen.getByRole('tab', { name: /TechDocs/ })).toBeInTheDocument(),
);
await waitFor(() =>
expect(screen.getByRole('tab', { name: /ApiDocs/ })).toBeInTheDocument(),
);
});
it('Should create a custom group', async () => {
const tester = createExtensionTester(
Object.assign({ namespace: 'catalog' }, catalogEntityPage),
{
config: {
groups: [
{
docs: { title: 'Docs' },
await renderInTestApp(
<TestApiProvider
apis={[
[catalogApiRef, mockCatalogApi],
[starredEntitiesApiRef, mockStarredEntitiesApi],
]}
>
{tester.reactElement()}
</TestApiProvider>,
{
config: {
app: {
title: 'Custom app',
},
],
backend: { baseUrl: 'http://localhost:7000' },
},
mountedRoutes: {
'/catalog': convertLegacyRouteRef(rootRouteRef),
'/catalog/:namespace/:kind/:name':
convertLegacyRouteRef(entityRouteRef),
},
},
},
)
.add(techdocsEntityContent, {
config: {
group: 'docs',
);
await waitFor(() =>
expect(screen.queryByRole('tab', { name: /Docs/ })).toBeInTheDocument(),
);
await userEvent.click(screen.getByRole('tab', { name: /Docs/ }));
await waitFor(() =>
expect(
screen.getByRole('button', { name: /TechDocs/ }),
).toHaveAttribute('href', '/techdocs'),
);
await waitFor(() =>
expect(screen.getByRole('button', { name: /ApiDocs/ })).toHaveAttribute(
'href',
'/apidocs',
),
);
});
it('Should disassociate a content with a default group', async () => {
const tester = createExtensionTester(
Object.assign({ namespace: 'catalog' }, catalogEntityPage),
)
.add(techdocsEntityContent)
.add(apidocsEntityContent, {
config: {
group: false,
},
});
await renderInTestApp(
<TestApiProvider
apis={[
[catalogApiRef, mockCatalogApi],
[starredEntitiesApiRef, mockStarredEntitiesApi],
]}
>
{tester.reactElement()}
</TestApiProvider>,
{
config: {
app: {
title: 'Custom app',
},
backend: { baseUrl: 'http://localhost:7000' },
},
mountedRoutes: {
'/catalog': convertLegacyRouteRef(rootRouteRef),
'/catalog/:namespace/:kind/:name':
convertLegacyRouteRef(entityRouteRef),
},
},
})
.add(apidocsEntityContent, {
config: {
group: 'docs',
);
await waitFor(() =>
expect(
screen.queryByRole('tab', { name: /Documentation/ }),
).not.toBeInTheDocument(),
);
await waitFor(() =>
expect(
screen.getByRole('tab', { name: /TechDocs/ }),
).toBeInTheDocument(),
);
await waitFor(() =>
expect(
screen.getByRole('tab', { name: /ApiDocs/ }),
).toBeInTheDocument(),
);
});
it('Should create a custom group', async () => {
const tester = createExtensionTester(
Object.assign({ namespace: 'catalog' }, catalogEntityPage),
{
config: {
groups: [
{
docs: { title: 'Docs' },
},
],
},
},
)
.add(techdocsEntityContent, {
config: {
group: 'docs',
},
})
.add(apidocsEntityContent, {
config: {
group: 'docs',
},
});
await renderInTestApp(
<TestApiProvider
apis={[
[catalogApiRef, mockCatalogApi],
[starredEntitiesApiRef, mockStarredEntitiesApi],
]}
>
{tester.reactElement()}
</TestApiProvider>,
{
config: {
app: {
title: 'Custom app',
},
backend: { baseUrl: 'http://localhost:7000' },
},
mountedRoutes: {
'/catalog': convertLegacyRouteRef(rootRouteRef),
'/catalog/:namespace/:kind/:name':
convertLegacyRouteRef(entityRouteRef),
},
},
);
await waitFor(() =>
expect(screen.getByRole('tab', { name: /Docs/ })).toBeInTheDocument(),
);
await userEvent.click(screen.getByRole('tab', { name: /Docs/ }));
await waitFor(() =>
expect(
screen.getByRole('button', { name: /TechDocs/ }),
).toHaveAttribute('href', '/techdocs'),
);
await waitFor(() =>
expect(screen.getByRole('button', { name: /ApiDocs/ })).toHaveAttribute(
'href',
'/apidocs',
),
);
});
it('Should render a single-content groups as a normal tab', async () => {
const tester = createExtensionTester(
Object.assign({ namespace: 'catalog' }, catalogEntityPage),
)
.add(techdocsEntityContent)
.add(apidocsEntityContent)
.add(overviewEntityContent, {
config: {
group: 'development',
},
});
await renderInTestApp(
<TestApiProvider
apis={[
[catalogApiRef, mockCatalogApi],
[starredEntitiesApiRef, mockStarredEntitiesApi],
]}
>
{tester.reactElement()}
</TestApiProvider>,
{
config: {
app: {
title: 'Custom app',
},
backend: { baseUrl: 'http://localhost:7000' },
},
mountedRoutes: {
'/catalog': convertLegacyRouteRef(rootRouteRef),
'/catalog/:namespace/:kind/:name':
convertLegacyRouteRef(entityRouteRef),
},
},
);
await waitFor(() =>
expect(
screen.getByRole('tab', { name: /Overview/ }),
).toBeInTheDocument(),
);
await waitFor(() =>
expect(
screen.queryByRole('tab', { name: /Development/ }),
).not.toBeInTheDocument(),
);
});
it('Should render groups first', async () => {
const tester = createExtensionTester(
Object.assign({ namespace: 'catalog' }, catalogEntityPage),
)
.add(techdocsEntityContent)
.add(apidocsEntityContent)
.add(overviewEntityContent);
await renderInTestApp(
<TestApiProvider
apis={[
[catalogApiRef, mockCatalogApi],
[starredEntitiesApiRef, mockStarredEntitiesApi],
]}
>
{tester.reactElement()}
</TestApiProvider>,
{
config: {
app: {
title: 'Custom app',
},
backend: { baseUrl: 'http://localhost:7000' },
},
mountedRoutes: {
'/catalog': convertLegacyRouteRef(rootRouteRef),
'/catalog/:namespace/:kind/:name':
convertLegacyRouteRef(entityRouteRef),
},
},
);
await waitFor(() => expect(screen.getAllByRole('tab')).toHaveLength(2));
expect(screen.getAllByRole('tab')[0]).toHaveTextContent('Documentation');
expect(screen.getAllByRole('tab')[1]).toHaveTextContent('Overview');
});
it('Should render groups on the correct order', async () => {
const tester = createExtensionTester(
Object.assign({ namespace: 'catalog' }, catalogEntityPage),
{
config: {
groups: [
{ overview: { title: 'Overview' } },
{ documentation: { title: 'Documentation' } },
],
},
},
)
.add(techdocsEntityContent)
.add(apidocsEntityContent)
.add(overviewEntityContent, {
config: {
group: 'overview',
},
});
await renderInTestApp(
<TestApiProvider
apis={[
[catalogApiRef, mockCatalogApi],
[starredEntitiesApiRef, mockStarredEntitiesApi],
]}
>
{tester.reactElement()}
</TestApiProvider>,
{
config: {
app: {
title: 'Custom app',
},
backend: { baseUrl: 'http://localhost:7000' },
},
mountedRoutes: {
'/catalog': convertLegacyRouteRef(rootRouteRef),
'/catalog/:namespace/:kind/:name':
convertLegacyRouteRef(entityRouteRef),
},
},
);
await waitFor(() => expect(screen.getAllByRole('tab')).toHaveLength(2));
expect(screen.getAllByRole('tab')[0]).toHaveTextContent('Overview');
expect(screen.getAllByRole('tab')[1]).toHaveTextContent('Documentation');
});
});
describe('Entity Page Headers', () => {
it('Should use the default header', async () => {
const tester = createExtensionTester(
Object.assign({ namespace: 'catalog' }, catalogEntityPage),
);
await renderInTestApp(
<TestApiProvider
apis={[
[catalogApiRef, mockCatalogApi],
[starredEntitiesApiRef, mockStarredEntitiesApi],
]}
>
{tester.reactElement()}
</TestApiProvider>,
{
config: {
app: {
title: 'Custom app',
},
backend: { baseUrl: 'http://localhost:7000' },
},
mountedRoutes: {
'/catalog': convertLegacyRouteRef(rootRouteRef),
'/catalog/:namespace/:kind/:name':
convertLegacyRouteRef(entityRouteRef),
},
},
);
await waitFor(() =>
expect(screen.getByText(/artist-lookup/)).toBeInTheDocument(),
);
});
it('Should render a totally different header element', async () => {
const customEntityHeader = EntityHeaderBlueprint.make({
name: 'default',
params: {
loader: async () => (
<header>
<h1>Custom header</h1>
</header>
),
},
});
await renderInTestApp(
<TestApiProvider
apis={[
[catalogApiRef, mockCatalogApi],
[starredEntitiesApiRef, mockStarredEntitiesApi],
]}
>
{tester.reactElement()}
</TestApiProvider>,
{
config: {
app: {
title: 'Custom app',
const tester = createExtensionTester(
Object.assign({ namespace: 'catalog' }, catalogEntityPage),
).add(customEntityHeader);
await renderInTestApp(
<TestApiProvider
apis={[
[catalogApiRef, mockCatalogApi],
[starredEntitiesApiRef, mockStarredEntitiesApi],
]}
>
{tester.reactElement()}
</TestApiProvider>,
{
config: {
app: {
title: 'Custom app',
},
backend: { baseUrl: 'http://localhost:7000' },
},
backend: { baseUrl: 'http://localhost:7000' },
},
mountedRoutes: {
'/catalog': convertLegacyRouteRef(rootRouteRef),
'/catalog/:namespace/:kind/:name':
convertLegacyRouteRef(entityRouteRef),
},
},
);
await waitFor(() =>
expect(screen.getByRole('tab', { name: /Docs/ })).toBeInTheDocument(),
);
await userEvent.click(screen.getByRole('tab', { name: /Docs/ }));
await waitFor(() =>
expect(screen.getByRole('button', { name: /TechDocs/ })).toHaveAttribute(
'href',
'/techdocs',
),
);
await waitFor(() =>
expect(screen.getByRole('button', { name: /ApiDocs/ })).toHaveAttribute(
'href',
'/apidocs',
),
);
});
it('Should render single-content groups as a normal tab', async () => {
const tester = createExtensionTester(
Object.assign({ namespace: 'catalog' }, catalogEntityPage),
)
.add(techdocsEntityContent)
.add(apidocsEntityContent)
.add(overviewEntityContent, {
config: {
group: 'development',
},
});
await renderInTestApp(
<TestApiProvider
apis={[
[catalogApiRef, mockCatalogApi],
[starredEntitiesApiRef, mockStarredEntitiesApi],
]}
>
{tester.reactElement()}
</TestApiProvider>,
{
config: {
app: {
title: 'Custom app',
mountedRoutes: {
'/catalog': convertLegacyRouteRef(rootRouteRef),
'/catalog/:namespace/:kind/:name':
convertLegacyRouteRef(entityRouteRef),
},
backend: { baseUrl: 'http://localhost:7000' },
},
mountedRoutes: {
'/catalog': convertLegacyRouteRef(rootRouteRef),
'/catalog/:namespace/:kind/:name':
convertLegacyRouteRef(entityRouteRef),
},
},
);
);
await waitFor(() =>
expect(screen.getByRole('tab', { name: /Overview/ })).toBeInTheDocument(),
);
await waitFor(() =>
expect(
screen.queryByRole('tab', { name: /Development/ }),
).not.toBeInTheDocument(),
);
});
it('Should render groups first', async () => {
const tester = createExtensionTester(
Object.assign({ namespace: 'catalog' }, catalogEntityPage),
)
.add(techdocsEntityContent)
.add(apidocsEntityContent)
.add(overviewEntityContent);
await renderInTestApp(
<TestApiProvider
apis={[
[catalogApiRef, mockCatalogApi],
[starredEntitiesApiRef, mockStarredEntitiesApi],
]}
>
{tester.reactElement()}
</TestApiProvider>,
{
config: {
app: {
title: 'Custom app',
},
backend: { baseUrl: 'http://localhost:7000' },
},
mountedRoutes: {
'/catalog': convertLegacyRouteRef(rootRouteRef),
'/catalog/:namespace/:kind/:name':
convertLegacyRouteRef(entityRouteRef),
},
},
);
await waitFor(() => expect(screen.getAllByRole('tab')).toHaveLength(2));
expect(screen.getAllByRole('tab')[0]).toHaveTextContent('Documentation');
expect(screen.getAllByRole('tab')[1]).toHaveTextContent('Overview');
});
it('Should render groups on the correct order', async () => {
const tester = createExtensionTester(
Object.assign({ namespace: 'catalog' }, catalogEntityPage),
{
config: {
groups: [
{ overview: { title: 'Overview' } },
{ documentation: { title: 'Documentation' } },
],
},
},
)
.add(techdocsEntityContent)
.add(apidocsEntityContent)
.add(overviewEntityContent, {
config: {
group: 'overview',
},
});
await renderInTestApp(
<TestApiProvider
apis={[
[catalogApiRef, mockCatalogApi],
[starredEntitiesApiRef, mockStarredEntitiesApi],
]}
>
{tester.reactElement()}
</TestApiProvider>,
{
config: {
app: {
title: 'Custom app',
},
backend: { baseUrl: 'http://localhost:7000' },
},
mountedRoutes: {
'/catalog': convertLegacyRouteRef(rootRouteRef),
'/catalog/:namespace/:kind/:name':
convertLegacyRouteRef(entityRouteRef),
},
},
);
await waitFor(() => expect(screen.getAllByRole('tab')).toHaveLength(2));
expect(screen.getAllByRole('tab')[0]).toHaveTextContent('Overview');
expect(screen.getAllByRole('tab')[1]).toHaveTextContent('Documentation');
await waitFor(() =>
expect(
screen.getByRole('heading', { name: /Custom header/ }),
).toBeInTheDocument(),
);
});
});
});
+32 -2
View File
@@ -29,12 +29,14 @@ import {
entityRouteRef,
} from '@backstage/plugin-catalog-react';
import {
EntityHeaderBlueprint,
EntityContentBlueprint,
defaultEntityContentGroups,
} from '@backstage/plugin-catalog-react/alpha';
import { rootRouteRef } from '../routes';
import { useEntityFromUrl } from '../components/CatalogEntityPage/useEntityFromUrl';
import { buildFilterFn } from './filter/FilterWrapper';
import { EntityHeader } from './components/EntityHeader';
export const catalogPage = PageBlueprint.makeWithOverrides({
inputs: {
@@ -58,6 +60,13 @@ export const catalogPage = PageBlueprint.makeWithOverrides({
export const catalogEntityPage = PageBlueprint.makeWithOverrides({
name: 'entity',
inputs: {
headers: createExtensionInput([
EntityHeaderBlueprint.dataRefs.filterFunction.optional(),
EntityHeaderBlueprint.dataRefs.filterExpression.optional(),
EntityHeaderBlueprint.dataRefs.element.optional(),
EntityHeaderBlueprint.dataRefs.title.optional(),
EntityHeaderBlueprint.dataRefs.subtitle.optional(),
]),
contents: createExtensionInput([
coreExtensionData.reactElement,
coreExtensionData.routePath,
@@ -81,6 +90,24 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({
defaultPath: '/catalog/:namespace/:kind/:name',
routeRef: convertLegacyRouteRef(entityRouteRef),
loader: async () => {
const headers = inputs.headers.map(header => {
const element = header.get(
EntityHeaderBlueprint.dataRefs.element,
) ?? (
<EntityHeader
title={header.get(EntityHeaderBlueprint.dataRefs.title)}
subtitle={header.get(EntityHeaderBlueprint.dataRefs.subtitle)}
/>
);
return {
filter: buildFilterFn(
header.get(EntityHeaderBlueprint.dataRefs.filterFunction),
header.get(EntityHeaderBlueprint.dataRefs.filterExpression),
),
element,
};
});
const { EntityLayout } = await import('./components/EntityLayout');
type Groups = Record<
@@ -123,9 +150,12 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({
}
const Component = () => {
const { entity, ...rest } = useEntityFromUrl();
const header = headers.find(({ filter }) => entity && filter(entity));
return (
<AsyncEntityProvider {...useEntityFromUrl()}>
<EntityLayout>
<AsyncEntityProvider {...rest} entity={entity}>
<EntityLayout header={header?.element}>
{Object.values(groups).flatMap(({ title, items }) =>
items.map(output => (
<EntityLayout.Route