Merge pull request #28701 from backstage/camilaibs/nfs-entity-content-groups

[NFS] Entity Page Content Groups
This commit is contained in:
Patrik Oldsberg
2025-02-19 15:18:59 +01:00
committed by GitHub
27 changed files with 1964 additions and 18 deletions
+30
View File
@@ -320,11 +320,13 @@ const _default: FrontendPlugin<
path: string | undefined;
title: string | undefined;
filter: string | undefined;
group: string | false | undefined;
};
configInput: {
filter?: string | undefined;
title?: string | undefined;
path?: string | undefined;
group?: string | false | undefined;
};
output:
| ConfigurableExtensionDataRef<
@@ -358,12 +360,25 @@ const _default: FrontendPlugin<
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
string | false,
'catalog.entity-content-group',
{
optional: true;
}
>;
inputs: {};
params: {
loader: () => Promise<JSX.Element>;
defaultPath: string;
defaultTitle: string;
defaultGroup?:
| 'documentation'
| 'development'
| 'deployment'
| 'observability'
| undefined;
routeRef?: RouteRef<AnyRouteRefParams> | undefined;
filter?: string | ((entity: Entity) => boolean) | undefined;
};
@@ -375,11 +390,13 @@ const _default: FrontendPlugin<
path: string | undefined;
title: string | undefined;
filter: string | undefined;
group: string | false | undefined;
};
configInput: {
filter?: string | undefined;
title?: string | undefined;
path?: string | undefined;
group?: string | false | undefined;
};
output:
| ConfigurableExtensionDataRef<
@@ -413,12 +430,25 @@ const _default: FrontendPlugin<
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
string | false,
'catalog.entity-content-group',
{
optional: true;
}
>;
inputs: {};
params: {
loader: () => Promise<JSX.Element>;
defaultPath: string;
defaultTitle: string;
defaultGroup?:
| 'documentation'
| 'development'
| 'deployment'
| 'observability'
| undefined;
routeRef?: RouteRef<AnyRouteRefParams> | undefined;
filter?: string | ((entity: Entity) => boolean) | undefined;
};
+28
View File
@@ -98,6 +98,14 @@ export function convertLegacyEntityContentExtension(
},
): ExtensionDefinition;
// @alpha
export const defaultEntityContentGroups: {
documentation: string;
development: string;
deployment: string;
observability: string;
};
// @alpha
export const EntityCardBlueprint: ExtensionBlueprint<{
kind: 'entity-card';
@@ -151,6 +159,12 @@ export const EntityContentBlueprint: ExtensionBlueprint<{
loader: () => Promise<JSX.Element>;
defaultPath: string;
defaultTitle: string;
defaultGroup?:
| 'documentation'
| 'development'
| 'deployment'
| 'observability'
| undefined;
routeRef?: RouteRef<AnyRouteRefParams> | undefined;
filter?: string | ((entity: Entity) => boolean) | undefined;
};
@@ -178,17 +192,26 @@ export const EntityContentBlueprint: ExtensionBlueprint<{
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
string | false,
'catalog.entity-content-group',
{
optional: true;
}
>;
inputs: {};
config: {
path: string | undefined;
title: string | undefined;
filter: string | undefined;
group: string | false | undefined;
};
configInput: {
filter?: string | undefined;
title?: string | undefined;
path?: string | undefined;
group?: string | false | undefined;
};
dataRefs: {
title: ConfigurableExtensionDataRef<
@@ -206,6 +229,11 @@ export const EntityContentBlueprint: ExtensionBlueprint<{
'catalog.entity-filter-expression',
{}
>;
group: ConfigurableExtensionDataRef<
string | false,
'catalog.entity-content-group',
{}
>;
};
}>;
@@ -56,6 +56,17 @@ describe('EntityContentBlueprint', () => {
"filter": {
"type": "string",
},
"group": {
"anyOf": [
{
"const": false,
"type": "boolean",
},
{
"type": "string",
},
],
},
"path": {
"type": "string",
},
@@ -102,6 +113,15 @@ describe('EntityContentBlueprint', () => {
"optional": [Function],
"toString": [Function],
},
{
"$$type": "@backstage/ExtensionDataRef",
"config": {
"optional": true,
},
"id": "catalog.entity-content-group",
"optional": [Function],
"toString": [Function],
},
],
"override": [Function],
"toString": [Function],
@@ -24,6 +24,8 @@ import {
entityContentTitleDataRef,
entityFilterFunctionDataRef,
entityFilterExpressionDataRef,
entityContentGroupDataRef,
defaultEntityContentGroups,
} from './extensionData';
/**
@@ -40,17 +42,20 @@ export const EntityContentBlueprint = createExtensionBlueprint({
coreExtensionData.routeRef.optional(),
entityFilterFunctionDataRef.optional(),
entityFilterExpressionDataRef.optional(),
entityContentGroupDataRef.optional(),
],
dataRefs: {
title: entityContentTitleDataRef,
filterFunction: entityFilterFunctionDataRef,
filterExpression: entityFilterExpressionDataRef,
group: entityContentGroupDataRef,
},
config: {
schema: {
path: z => z.string().optional(),
title: z => z.string().optional(),
filter: z => z.string().optional(),
group: z => z.literal(false).or(z.string()).optional(),
},
},
*factory(
@@ -58,12 +63,14 @@ export const EntityContentBlueprint = createExtensionBlueprint({
loader,
defaultPath,
defaultTitle,
defaultGroup,
filter,
routeRef,
}: {
loader: () => Promise<JSX.Element>;
defaultPath: string;
defaultTitle: string;
defaultGroup?: keyof typeof defaultEntityContentGroups;
routeRef?: RouteRef;
filter?:
| typeof entityFilterFunctionDataRef.T
@@ -73,6 +80,7 @@ export const EntityContentBlueprint = createExtensionBlueprint({
) {
const path = config.path ?? defaultPath;
const title = config.title ?? defaultTitle;
const group = config.group ?? defaultGroup;
yield coreExtensionData.reactElement(ExtensionBoundary.lazy(node, loader));
@@ -91,5 +99,9 @@ export const EntityContentBlueprint = createExtensionBlueprint({
} else if (typeof filter === 'function') {
yield entityFilterFunctionDataRef(filter);
}
if (group) {
yield entityContentGroupDataRef(group);
}
},
});
@@ -32,3 +32,21 @@ export const entityFilterExpressionDataRef =
createExtensionDataRef<string>().with({
id: 'catalog.entity-filter-expression',
});
/**
* @alpha
* Default entity content groups.
*/
export const defaultEntityContentGroups = {
documentation: 'Documentation',
development: 'Development',
deployment: 'Deployment',
observability: 'Observability',
};
/** @internal */
export const entityContentGroupDataRef = createExtensionDataRef<
false | string
>().with({
id: 'catalog.entity-content-group',
});
@@ -15,3 +15,4 @@
*/
export { EntityCardBlueprint } from './EntityCardBlueprint';
export { EntityContentBlueprint } from './EntityContentBlueprint';
export { defaultEntityContentGroups } from './extensionData';
+2
View File
@@ -77,11 +77,13 @@
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.61",
"@mui/utils": "^5.14.15",
"classnames": "^2.3.1",
"dataloader": "^2.0.0",
"expiry-map": "^2.0.0",
"history": "^5.0.0",
"lodash": "^4.17.21",
"pluralize": "^8.0.0",
"react-helmet": "6.1.0",
"react-use": "^17.2.4",
"zen-observable": "^0.10.0"
},
+42
View File
@@ -527,11 +527,13 @@ const _default: FrontendPlugin<
path: string | undefined;
title: string | undefined;
filter: string | undefined;
group: string | false | undefined;
};
configInput: {
filter?: string | undefined;
title?: string | undefined;
path?: string | undefined;
group?: string | false | undefined;
};
output:
| ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
@@ -561,6 +563,13 @@ const _default: FrontendPlugin<
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
string | false,
'catalog.entity-content-group',
{
optional: true;
}
>;
inputs: {
cards: ExtensionInput<
@@ -591,6 +600,12 @@ const _default: FrontendPlugin<
loader: () => Promise<JSX.Element>;
defaultPath: string;
defaultTitle: string;
defaultGroup?:
| 'documentation'
| 'development'
| 'deployment'
| 'observability'
| undefined;
routeRef?: RouteRef<AnyRouteRefParams> | undefined;
filter?: string | ((entity: Entity) => boolean) | undefined;
};
@@ -787,9 +802,29 @@ const _default: FrontendPlugin<
}>;
'page:catalog/entity': ExtensionDefinition<{
config: {
groups:
| Record<
string,
| false
| {
title: string;
}
>[]
| undefined;
} & {
path: string | undefined;
};
configInput: {
groups?:
| Record<
string,
| false
| {
title: string;
}
>[]
| undefined;
} & {
path?: string | undefined;
};
output:
@@ -831,6 +866,13 @@ const _default: FrontendPlugin<
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
string | false,
'catalog.entity-content-group',
{
optional: true;
}
>,
{
singleton: false;
@@ -0,0 +1,58 @@
/*
* 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 { HeaderLabel } from '@backstage/core-components';
import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model';
import {
EntityRefLinks,
getEntityRelations,
} from '@backstage/plugin-catalog-react';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { catalogTranslationRef } from '../../../alpha/translation';
type EntityLabelsProps = {
entity: Entity;
};
export function EntityLabels(props: EntityLabelsProps) {
const { entity } = props;
const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY);
const { t } = useTranslationRef(catalogTranslationRef);
return (
<>
{ownedByRelations.length > 0 && (
<HeaderLabel
label={t('entityLabels.ownerLabel')}
contentTypograpyRootComponent="p"
value={
<EntityRefLinks
entityRefs={ownedByRelations}
defaultKind="Group"
color="inherit"
/>
}
/>
)}
{entity.spec?.lifecycle && (
<HeaderLabel
label={t('entityLabels.lifecycleLabel')}
value={entity.spec.lifecycle?.toString()}
/>
)}
</>
);
}
@@ -0,0 +1,361 @@
/*
* Copyright 2020 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, { ComponentProps, useEffect, useState } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import useAsync from 'react-use/esm/useAsync';
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 {
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 { EntityTabs } from '../EntityTabs';
import { EntityLabels } from '../EntityLabels/EntityLabels';
import { EntityLayoutTitle } from './EntityLayoutTitle';
export type EntityLayoutRouteProps = {
path: string;
title: string;
group: string;
children: JSX.Element;
if?: (entity: Entity) => boolean;
};
const dataKey = 'plugin.catalog.entityLayoutRoute';
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;
/**
* 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[];
}
/**
* EntityLayout is a compound component, which allows you to define a layout for
* entities using a sub-navigation mechanism.
*
* Consists of two parts: EntityLayout and EntityLayout.Route
*
* @example
* ```jsx
* <EntityLayout>
* <EntityLayout.Route path="/example" title="Example tab">
* <div>This is rendered under /example/anything-here route</div>
* </EntityLayout.Route>
* </EntityLayout>
* ```
*
* @public
*/
export const EntityLayout = (props: EntityLayoutProps) => {
const {
UNSTABLE_extraContextMenuItems,
UNSTABLE_contextMenuOptions,
children,
NotFoundComponent,
parentEntityRelations,
} = props;
const classes = useStyles();
const { kind, namespace, name } = useRouteRefParams(entityRouteRef);
const { entity, loading, error } = useAsyncEntity();
const location = useLocation();
const routes = useElementFilter(
children,
elements =>
elements
.selectByComponentData({
key: dataKey,
withStrictError:
'Child of EntityLayout must be an EntityLayout.Route',
})
.getElements<EntityLayoutRouteProps>() // all nodes, element data, maintain structure or not?
.flatMap(({ props: elementProps }) => {
if (!entity) {
return [];
}
if (elementProps.if && !elementProps.if(entity)) {
return [];
}
return [
{
path: elementProps.path,
title: elementProps.title,
group: elementProps.group,
children: elementProps.children,
},
];
}),
[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>
{loading && <Progress />}
{entity && <EntityTabs routes={routes} />}
{error && (
<Content>
<Alert severity="error">{error.toString()}</Alert>
</Content>
)}
{!loading && !error && !entity && (
<Content>
{NotFoundComponent ? (
NotFoundComponent
) : (
<WarningPanel title={t('entityLabels.warningPanelTitle')}>
There is no {kind} with the requested{' '}
<Link to="https://backstage.io/docs/features/software-catalog/references">
kind, namespace, and name
</Link>
.
</WarningPanel>
)}
</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>
);
};
EntityLayout.Route = Route;
@@ -0,0 +1,45 @@
/*
* 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>
);
}
@@ -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 { EntityLayout } from './EntityLayout';
@@ -0,0 +1,107 @@
/*
* Copyright 2020 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, { useMemo } from 'react';
import { Helmet } from 'react-helmet';
import { matchRoutes, useParams, useRoutes } from 'react-router-dom';
import { EntityTabsPanel } from './EntityTabsPanel';
import { EntityTabsList } from './EntityTabsList';
type SubRoute = {
group: string;
path: string;
title: string;
children: JSX.Element;
};
export function useSelectedSubRoute(subRoutes: SubRoute[]): {
index: number;
route?: SubRoute;
element?: JSX.Element;
} {
const params = useParams();
const routes = subRoutes.map(({ path, children }) => ({
caseSensitive: false,
path: `${path}/*`,
element: children,
}));
// TODO: remove once react-router updated
const sortedRoutes = routes.sort((a, b) =>
// remove "/*" symbols from path end before comparing
b.path.replace(/\/\*$/, '').localeCompare(a.path.replace(/\/\*$/, '')),
);
const element = useRoutes(sortedRoutes) ?? subRoutes[0]?.children;
// TODO(Rugvip): Once we only support v6 stable we can always prefix
// This avoids having a double / prefix for react-router v6 beta, which in turn breaks
// the tab highlighting when using relative paths for the tabs.
let currentRoute = params['*'] ?? '';
if (!currentRoute.startsWith('/')) {
currentRoute = `/${currentRoute}`;
}
const [matchedRoute] = matchRoutes(sortedRoutes, currentRoute) ?? [];
const foundIndex = matchedRoute
? subRoutes.findIndex(t => `${t.path}/*` === matchedRoute.route.path)
: 0;
return {
index: foundIndex === -1 ? 0 : foundIndex,
element,
route: subRoutes[foundIndex] ?? subRoutes[0],
};
}
type EntityTabsProps = {
routes: SubRoute[];
};
export function EntityTabs(props: EntityTabsProps) {
const { routes } = props;
const { index, route, element } = useSelectedSubRoute(routes);
const tabs = useMemo(
() =>
routes.map(t => {
const { path, title, group } = t;
let to = path;
// Remove trailing /*
to = to.replace(/\/\*$/, '');
// And remove leading / for relative navigation
to = to.replace(/^\//, '');
return {
group,
id: path,
path: to,
label: title,
};
}),
[routes],
);
return (
<>
<EntityTabsList tabs={tabs} selectedIndex={index} />
<EntityTabsPanel>
<Helmet title={route?.title} />
{element}
</EntityTabsPanel>
</>
);
}
@@ -0,0 +1,298 @@
/*
* 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, MouseEvent, MouseEventHandler } from 'react';
import { Link } from 'react-router-dom';
import classnames from 'classnames';
import Typography from '@material-ui/core/Typography';
import ButtonBase from '@material-ui/core/ButtonBase';
import Popover from '@material-ui/core/Popover';
import { TabProps, TabClassKey } from '@material-ui/core/Tab';
import { capitalize } from '@material-ui/core/utils';
import { createStyles, Theme, withStyles } from '@material-ui/core/styles';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
const styles = (theme: Theme) =>
createStyles({
/* Styles applied to the root element. */
root: {
...theme.typography.button,
maxWidth: 264,
minWidth: 72,
position: 'relative',
boxSizing: 'border-box',
minHeight: 48,
flexShrink: 0,
padding: '6px 12px',
[theme.breakpoints.up('sm')]: {
padding: '6px 24px',
},
overflow: 'hidden',
whiteSpace: 'normal',
textAlign: 'center',
[theme.breakpoints.up('sm')]: {
minWidth: 160,
},
},
popInButton: {
width: '100%',
},
defaultTab: {
...theme.typography.caption,
padding: theme.spacing(3, 3),
textTransform: 'uppercase',
fontWeight: theme.typography.fontWeightBold,
color: theme.palette.text.secondary,
},
/* Styles applied to the root element if both `icon` and `label` are provided. */
labelIcon: {
minHeight: 72,
paddingTop: 9,
'& $wrapper > *:first-child': {
marginBottom: 6,
},
},
/* Styles applied to the root element if the parent [`Tabs`](/api/tabs/) has `textColor="inherit"`. */
textColorInherit: {
color: 'inherit',
opacity: 0.7,
'&$selected': {
opacity: 1,
},
'&$disabled': {
opacity: 0.5,
},
},
selectedButton: {
color: `${theme.palette.text.primary}`,
opacity: `${1}`,
},
unselectedButton: {
color: `${theme.palette.text.secondary}`,
opacity: `${0.7}`,
},
/* Styles applied to the root element if the parent [`Tabs`](/api/tabs/) has `textColor="primary"`. */
textColorPrimary: {
color: theme.palette.text.secondary,
'&$selected': {
color: theme.palette.primary.main,
},
'&$disabled': {
color: theme.palette.text.disabled,
},
},
/* Styles applied to the root element if the parent [`Tabs`](/api/tabs/) has `textColor="secondary"`. */
textColorSecondary: {
color: theme.palette.text.secondary,
'&$selected': {
color: theme.palette.secondary.main,
},
'&$disabled': {
color: theme.palette.text.disabled,
},
},
/* Pseudo-class applied to the root element if `selected={true}` (controlled by the Tabs component). */
selected: {},
/* Pseudo-class applied to the root element if `disabled={true}` (controlled by the Tabs component). */
disabled: {},
/* Styles applied to the root element if `fullWidth={true}` (controlled by the Tabs component). */
fullWidth: {
flexShrink: 1,
flexGrow: 1,
flexBasis: 0,
maxWidth: 'none',
},
/* Styles applied to the root element if `wrapped={true}`. */
wrapped: {
fontSize: theme.typography.pxToRem(12),
lineHeight: 1.5,
},
/* Styles applied to the `icon` and `label`'s wrapper element. */
wrapper: {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
flexDirection: 'row',
},
});
type EntityTabsGroupItem = {
id: string;
index: number;
label: string;
path: string;
group: string;
};
type EntityTabsGroupProps = TabProps & {
classes?: Partial<ReturnType<typeof styles>>;
indicator?: React.ReactNode;
highlightedButton?: number;
items: EntityTabsGroupItem[];
onSelectTab: MouseEventHandler<HTMLAnchorElement>;
};
const Tab = React.forwardRef(function Tab(
props: EntityTabsGroupProps,
ref: any,
) {
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
const open = Boolean(anchorEl);
const submenuId = open ? 'tabbed-submenu' : undefined;
const {
classes,
className,
disabled = false,
disableFocusRipple = false,
items,
fullWidth,
icon,
indicator,
label,
onSelectTab,
selected,
textColor = 'inherit',
wrapped = false,
highlightedButton,
} = props;
const testId = 'data-testid' in props && props['data-testid'];
const handleMenuClose = () => {
setAnchorEl(null);
};
const handleMenuClick = (event: MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
};
const classArray = [
classes?.root,
classes?.[`textColor${capitalize(textColor)}` as TabClassKey],
classes && {
[classes.disabled!]: disabled,
[classes.selected!]: selected,
[classes.labelIcon!]: label && icon,
[classes.fullWidth!]: fullWidth,
[classes.wrapped!]: wrapped,
},
className,
];
const innerButtonClasses = [
classes?.root,
classes?.[`textColor${capitalize(textColor)}` as TabClassKey],
classes?.defaultTab,
classes && {
[classes.disabled!]: disabled,
[classes.labelIcon!]: label && icon,
[classes.fullWidth!]: fullWidth,
[classes.wrapped!]: wrapped,
},
];
if (items.length === 1) {
return (
<ButtonBase
focusRipple={!disableFocusRipple}
data-testid={testId}
className={classnames(classArray)}
ref={ref}
role="tab"
aria-selected={selected}
disabled={disabled}
component={Link}
onClick={onSelectTab}
to={items[0]?.path}
>
<Typography className={classes?.wrapper} variant="button">
{icon}
{items[0].label}
</Typography>
{indicator}
</ButtonBase>
);
}
return (
<>
<ButtonBase
data-testid={testId}
focusRipple={!disableFocusRipple}
className={classnames(classArray)}
ref={ref}
role="tab"
aria-selected={selected}
disabled={disabled}
onClick={handleMenuClick}
>
<Typography className={classes?.wrapper} variant="button">
{label}
</Typography>
<ExpandMoreIcon />
</ButtonBase>
<Popover
id={submenuId}
open={open}
anchorEl={anchorEl}
onClose={handleMenuClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'center',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'center',
}}
>
{items.map((i, idx) => (
<div key={`popover_item_${idx}`}>
<ButtonBase
focusRipple={!disableFocusRipple}
className={classnames(
innerButtonClasses,
classes?.popInButton,
highlightedButton === i.index
? classes?.selectedButton
: classes?.unselectedButton,
)}
ref={ref}
aria-selected={selected}
disabled={disabled}
component={Link}
onClick={e => {
handleMenuClose();
onSelectTab(e);
}}
to={i.path}
>
<Typography className={classes?.wrapper} variant="button">
{icon}
{i.label}
</Typography>
{indicator}
</ButtonBase>
</div>
))}
</Popover>
</>
);
});
// @ts-ignore
export const EntityTabsGroup = withStyles(styles, { name: 'MuiTab' })(Tab);
@@ -0,0 +1,145 @@
/*
* 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, { useCallback, useEffect, useMemo, useState } from 'react';
import Box from '@material-ui/core/Box';
import Tabs from '@material-ui/core/Tabs';
import { makeStyles } from '@material-ui/core/styles';
import { EntityTabsGroup } from './EntityTabsGroup';
/** @public */
export type HeaderTabsClassKey =
| 'tabsWrapper'
| 'defaultTab'
| 'selected'
| 'tabRoot';
const useStyles = makeStyles(
theme => ({
tabsWrapper: {
gridArea: 'pageSubheader',
backgroundColor: theme.palette.background.paper,
paddingLeft: theme.spacing(3),
minWidth: 0,
},
defaultTab: {
...theme.typography.caption,
padding: theme.spacing(3, 3),
textTransform: 'uppercase',
fontWeight: theme.typography.fontWeightBold,
color: theme.palette.text.secondary,
},
selected: {
color: theme.palette.text.primary,
},
tabRoot: {
'&:hover': {
backgroundColor: theme.palette.background.default,
color: theme.palette.text.primary,
},
},
}),
{ name: 'BackstageHeaderTabs' },
);
type Tab = {
id: string;
label: string;
path: string;
group: string;
};
type TabItem = {
group: string;
id: string;
index: number;
label: string;
path: string;
};
type EntityTabsListProps = {
tabs: Tab[];
selectedIndex?: number;
onChange?: (index: number) => void;
};
export function EntityTabsList(props: EntityTabsListProps) {
const styles = useStyles();
const { tabs: items, onChange, selectedIndex: selectedItem = 0 } = props;
const groups = useMemo(
() => [...new Set(items.map(item => item.group))],
[items],
);
const [selectedGroup, setSelectedGroup] = useState<number>(
selectedItem && items[selectedItem]
? groups.indexOf(items[selectedItem].group)
: 0,
);
const handleChange = useCallback(
(index: number) => {
if (selectedItem !== index) onChange?.(index);
},
[selectedItem, onChange],
);
useEffect(() => {
if (selectedItem === undefined || !items[selectedItem]) return;
setSelectedGroup(groups.indexOf(items[selectedItem].group));
}, [items, selectedItem, groups, setSelectedGroup]);
return (
<Box className={styles.tabsWrapper}>
<Tabs
selectionFollowsFocus
indicatorColor="primary"
textColor="inherit"
variant="scrollable"
scrollButtons="auto"
aria-label="tabs"
value={selectedGroup}
>
{groups.map((group, groupIndex) => {
const groupItems: TabItem[] = [];
items.forEach((item, itemIndex) => {
if (item.group === group) {
groupItems.push({
...item,
index: itemIndex,
});
}
});
return (
<EntityTabsGroup
data-testid={`header-tab-${groupIndex}`}
className={styles.defaultTab}
classes={{ selected: styles.selected, root: styles.tabRoot }}
key={group}
label={group}
value={groupIndex}
items={groupItems}
highlightedButton={selectedItem}
onSelectTab={() => handleChange(groupIndex)}
/>
);
})}
</Tabs>
</Box>
);
}
@@ -0,0 +1,71 @@
/*
* 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 classNames from 'classnames';
import React, { PropsWithChildren } from 'react';
import { makeStyles, Theme } from '@material-ui/core/styles';
/** @public */
export type EntityTabsPanelClassKey = 'root' | 'stretch' | 'noPadding';
const useStyles = makeStyles(
(theme: Theme) => ({
root: {
gridArea: 'pageContent',
minWidth: 0,
paddingTop: theme.spacing(3),
paddingBottom: theme.spacing(3),
paddingLeft: theme.spacing(2),
paddingRight: theme.spacing(2),
[theme.breakpoints.up('sm')]: {
paddingLeft: theme.spacing(3),
paddingRight: theme.spacing(3),
},
},
stretch: {
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
},
noPadding: {
padding: 0,
},
}),
{ name: 'EntityTabsPanel' },
);
type EntityTabsPanelProps = PropsWithChildren<{
stretch?: boolean;
noPadding?: boolean;
className?: string;
}>;
export function EntityTabsPanel(props: EntityTabsPanelProps) {
const { className, stretch, noPadding, children, ...restProps } = props;
const classes = useStyles();
return (
<article
{...restProps}
className={classNames(classes.root, className, {
[classes.stretch]: stretch,
[classes.noPadding]: noPadding,
})}
>
{children}
</article>
);
}
@@ -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 { EntityTabs } from './EntityTabs';
+477
View File
@@ -0,0 +1,477 @@
/*
* 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 { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
createExtensionTester,
renderInTestApp,
TestApiProvider,
} from '@backstage/frontend-test-utils';
import { catalogEntityPage } from './pages';
import { EntityContentBlueprint } from '@backstage/plugin-catalog-react/alpha';
import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils';
import {
catalogApiRef,
entityRouteRef,
MockStarredEntitiesApi,
starredEntitiesApiRef,
} from '@backstage/plugin-catalog-react';
import { convertLegacyRouteRef } from '@backstage/core-compat-api';
import { rootRouteRef } from '../routes';
describe('Index page', () => {
const entityMock = {
metadata: {
namespace: 'default',
annotations: {
'backstage.io/managed-by-location':
'file:/Users/camilal/Workspace/backstage/packages/catalog-model/examples/components/artist-lookup-component.yaml',
'backstage.io/managed-by-origin-location':
'file:/Users/camilal/Workspace/backstage/packages/catalog-model/examples/all.yaml',
'backstage.io/source-template': 'template:default/springboot-template',
'backstage.io/linguist':
'https://github.com/backstage/backstage/tree/master/plugins/playlist',
},
name: 'artist-lookup',
description: 'Artist Lookup',
tags: ['java', 'data'],
links: [
{
url: 'https://example.com/user',
title: 'Examples Users',
icon: 'user',
},
{
url: 'https://example.com/group',
title: 'Example Group',
icon: 'group',
},
{
url: 'https://example.com/cloud',
title: 'Link with Cloud Icon',
icon: 'cloud',
},
{
url: 'https://example.com/dashboard',
title: 'Dashboard',
icon: 'dashboard',
},
{ url: 'https://example.com/help', title: 'Support', icon: 'help' },
{ url: 'https://example.com/web', title: 'Website', icon: 'web' },
{
url: 'https://example.com/alert',
title: 'Alerts',
icon: 'alert',
},
],
uid: '0dc69d61-4715-4912-bd7d-a0d44b421db0',
etag: 'dcebc518ac79e77356cb34df119a523de51cd522',
},
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
spec: {
type: 'service',
lifecycle: 'experimental',
owner: 'team-a',
system: 'artist-engagement-portal',
dependsOn: ['resource:artists-db'],
apiConsumedBy: ['component:www-artist'],
},
relations: [
{ type: 'apiConsumedBy', targetRef: 'component:default/www-artist' },
{ type: 'dependsOn', targetRef: 'resource:default/artists-db' },
{ type: 'ownedBy', targetRef: 'group:default/team-a' },
{
type: 'partOf',
targetRef: 'system:default/artist-engagement-portal',
},
],
};
const mockCatalogApi = catalogApiMock.mock({
getEntityByRef: async () => entityMock,
});
const mockStarredEntitiesApi = new MockStarredEntitiesApi();
const overviewEntityContent = EntityContentBlueprint.make({
name: 'overview',
params: {
defaultPath: '/overview',
defaultTitle: 'Overview',
defaultGroup: 'documentation',
loader: async () => <div>Mock Overview content</div>,
},
});
const techdocsEntityContent = EntityContentBlueprint.make({
name: 'techdocs',
params: {
defaultPath: '/techdocs',
defaultTitle: 'TechDocs',
defaultGroup: 'documentation',
loader: async () => <div>Mock TechDocs content</div>,
},
});
const apidocsEntityContent = EntityContentBlueprint.make({
name: 'apidocs',
params: {
defaultPath: '/apidocs',
defaultTitle: 'ApiDocs',
defaultGroup: 'documentation',
loader: async () => <div>Mock ApiDocs content</div>,
},
});
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' },
},
],
},
},
)
.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.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 disable a default group', async () => {
const tester = createExtensionTester(
Object.assign({ namespace: 'catalog' }, catalogEntityPage),
{
config: {
groups: [
{
documentation: false,
},
],
},
},
)
.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.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 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),
},
},
);
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 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(),
);
});
});
+61 -7
View File
@@ -28,7 +28,10 @@ import {
AsyncEntityProvider,
entityRouteRef,
} from '@backstage/plugin-catalog-react';
import { EntityContentBlueprint } from '@backstage/plugin-catalog-react/alpha';
import {
EntityContentBlueprint,
defaultEntityContentGroups,
} from '@backstage/plugin-catalog-react/alpha';
import { rootRouteRef } from '../routes';
import { useEntityFromUrl } from '../components/CatalogEntityPage/useEntityFromUrl';
import { buildFilterFn } from './filter/FilterWrapper';
@@ -62,21 +65,71 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({
EntityContentBlueprint.dataRefs.title,
EntityContentBlueprint.dataRefs.filterFunction.optional(),
EntityContentBlueprint.dataRefs.filterExpression.optional(),
EntityContentBlueprint.dataRefs.group.optional(),
]),
},
factory(originalFactory, { inputs }) {
config: {
schema: {
groups: z =>
z
.array(
z.record(
z.string(),
z.literal(false).or(z.object({ title: z.string() })),
),
)
.optional(),
},
},
factory(originalFactory, { config, inputs }) {
return originalFactory({
defaultPath: '/catalog/:namespace/:kind/:name',
routeRef: convertLegacyRouteRef(entityRouteRef),
loader: async () => {
const { EntityLayout } = await import('../components/EntityLayout');
const { EntityLayout } = await import('./components/EntityLayout');
// config groups override default groups
const groups: Record<string, string> = config.groups?.length
? config.groups.reduce<Record<string, string>>((rest, group) => {
const [groupId, groupValue] = Object.entries(group)[0];
return groupValue
? {
...rest,
[groupId]: groupValue.title,
}
: rest;
}, {})
: defaultEntityContentGroups;
// the groups order is determined by the order of the contents
// a group will appear in the order of the first item that belongs to it
const tabs = inputs.contents.reduce<
Record<string, Array<(typeof inputs.contents)[0]>>
>((rest, output) => {
const itemTitle = output.get(EntityContentBlueprint.dataRefs.title);
const groupId = output.get(EntityContentBlueprint.dataRefs.group);
const groupTitle = groupId && groups[groupId];
// disabled or invalid groups are ignored
if (!groupTitle) {
return {
...rest,
[itemTitle]: [output],
};
}
return {
...rest,
[groupTitle]: [...(rest[groupTitle] ?? []), output],
};
}, {});
const Component = () => {
return (
<AsyncEntityProvider {...useEntityFromUrl()}>
<EntityLayout>
{inputs.contents.map(output => {
return (
{Object.entries(tabs).flatMap(([group, items]) =>
items.map(output => (
<EntityLayout.Route
group={group}
key={output.get(coreExtensionData.routePath)}
path={output.get(coreExtensionData.routePath)}
title={output.get(EntityContentBlueprint.dataRefs.title)}
@@ -91,12 +144,13 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({
>
{output.get(coreExtensionData.reactElement)}
</EntityLayout.Route>
);
})}
)),
)}
</EntityLayout>
</AsyncEntityProvider>
);
};
return compatWrapper(<Component />);
},
});
+15
View File
@@ -70,11 +70,13 @@ const _default: FrontendPlugin<
path: string | undefined;
title: string | undefined;
filter: string | undefined;
group: string | false | undefined;
};
configInput: {
filter?: string | undefined;
title?: string | undefined;
path?: string | undefined;
group?: string | false | undefined;
};
output:
| ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
@@ -104,12 +106,25 @@ const _default: FrontendPlugin<
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
string | false,
'catalog.entity-content-group',
{
optional: true;
}
>;
inputs: {};
params: {
loader: () => Promise<JSX.Element>;
defaultPath: string;
defaultTitle: string;
defaultGroup?:
| 'documentation'
| 'development'
| 'deployment'
| 'observability'
| undefined;
routeRef?: RouteRef<AnyRouteRefParams> | undefined;
filter?: string | ((entity: Entity) => boolean) | undefined;
};
@@ -23,6 +23,7 @@ export const entityKubernetesContent = EntityContentBlueprint.make({
params: {
defaultPath: '/kubernetes',
defaultTitle: 'Kubernetes',
defaultGroup: 'deployment',
filter: 'kind:component,resource',
loader: () =>
import('./KubernetesContentPage').then(m =>
+15
View File
@@ -185,11 +185,13 @@ const _default: FrontendPlugin<
path: string | undefined;
title: string | undefined;
filter: string | undefined;
group: string | false | undefined;
};
configInput: {
filter?: string | undefined;
title?: string | undefined;
path?: string | undefined;
group?: string | false | undefined;
};
output:
| ConfigurableExtensionDataRef<
@@ -223,6 +225,13 @@ const _default: FrontendPlugin<
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
string | false,
'catalog.entity-content-group',
{
optional: true;
}
>;
inputs: {
emptyState: ExtensionInput<
@@ -245,6 +254,12 @@ const _default: FrontendPlugin<
loader: () => Promise<JSX.Element>;
defaultPath: string;
defaultTitle: string;
defaultGroup?:
| 'documentation'
| 'development'
| 'deployment'
| 'observability'
| undefined;
routeRef?: RouteRef<AnyRouteRefParams> | undefined;
filter?: string | ((entity: Entity) => boolean) | undefined;
};