feat: [NFS] entity page groups

Signed-off-by: Camila Belo <camilaibs@gmail.com>
This commit is contained in:
Camila Belo
2025-02-04 00:53:22 +01:00
parent 9152109393
commit ba9649ab84
27 changed files with 1940 additions and 18 deletions
+50
View File
@@ -0,0 +1,50 @@
---
'@backstage/plugin-catalog-react': minor
---
Add a new `defaultGroup` parameter to the `EntityContentBlueprint`, here are usage examples:
Set a default group while creating the extension:
```diff
const entityKubernetesContent = EntityContentBlueprint.make({
name: 'kubernetes',
params: {
defaultPath: '/kubernetes',
defaultTitle: 'Kubernetes',
+ defaultGroup: 'deployment',
filter: 'kind:component,resource',
loader: () =>
import('./KubernetesContentPage').then(m =>
compatWrapper(<m.KubernetesContentPage />),
),
},
});
```
Disassociate an entity content from a default group:
```diff
# app-config.yaml
app:
extensions:
# Entity page content
- - entity-content:kubernetes/kubernetes
+ - entity-content:kubernetes/kubernetes:
+ config:
+ group: false
```
Associate an entity content with a different default or custom group than the one defined in code when the extension was created:
```diff
# app-config.yaml
app:
extensions:
# Entity page content
- - entity-content:kubernetes/kubernetes
+ - entity-content:kubernetes/kubernetes:
+ config:
+ group: custom # associating this extension with a custom group id, the group should have previously been created via entity page configuration
```
+45
View File
@@ -0,0 +1,45 @@
---
'@backstage/plugin-catalog': patch
---
Update the default entity page extension component to support grouping multiple entity content items in the same tab.
Disable a default group via configuration:
```diff
# app-config.yaml
app:
extensions:
# Pages
+ - page:catalog/entity:
+ config:
+ groups:
+ deployment: false
```
Change a default group title via configuration:
```diff
# app-config.yaml
app:
extensions:
# Pages
+ - page:catalog/entity:
+ config:
+ groups:
+ deployment: Infrastructure # this is overriding the default group title
```
Create a custom entity content group via configuration:
```diff
# app-config.yaml
app:
extensions:
# Pages
+ - page:catalog/entity:
+ config:
+ groups:
+ # <id>: <Title>
+ custom: Custom
```
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-kubernetes': patch
---
Set deployment as the default group of Kubernetes entity content. It is just an example and shouldn't cause any visual difference since entity page tabs with just one entity content appear as normal tabs.
+33 -11
View File
@@ -12,6 +12,18 @@ app:
# - apis.plugin.graphiql.browse.gitlab: true
# - graphiql-endpoint:graphiql/gitlab: true
# Pages
- page:catalog/entity:
config:
groups:
# example disabling a default group
development: false
# example overriding a default group title
documentation: Docs
# example adding a new group
# Format <GROUP_ID>: <GROUP_TITLE>
custom: Custom
# Entity page cards
- entity-card:catalog/about
- entity-card:catalog/labels
@@ -27,23 +39,33 @@ app:
- entity-card:api-docs/provided-apis
- entity-card:api-docs/providing-components
- entity-card:api-docs/consuming-components
# - entity-card:azure-devops/readme
# Entity page content
- entity-content:api-docs/definition
- entity-content:api-docs/apis
- entity-content:techdocs
# - entity-content:azure-devops/pipelines
# - entity-content:azure-devops/pull-requests
# - entity-content:azure-devops/git-tags
# Org Plugin
- entity-card:org/group-profile
- entity-card:org/members-list
- entity-card:org/ownership
- entity-card:org/user-profile
# - entity-card:azure-devops/readme
- entity-content:kubernetes/kubernetes
# Entity page contents
- entity-content:catalog/overview:
config:
# associating with a custom group
group: custom
- entity-content:api-docs/definition
- entity-content:api-docs/apis:
config:
# example associating with a default group
group: documentation
- entity-content:techdocs:
config:
group: documentation
- entity-content:kubernetes/kubernetes:
config:
# example disassociating with a default group
group: false
# - entity-content:azure-devops/pipelines
# - entity-content:azure-devops/pull-requests
# - entity-content:azure-devops/git-tags
# scmAuthExtension: >-
# createScmAuthExtension({
+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"
},
+26
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,13 @@ const _default: FrontendPlugin<
}>;
'page:catalog/entity': ExtensionDefinition<{
config: {
groups: Record<string, string | false> | undefined;
} & {
path: string | undefined;
};
configInput: {
groups?: Record<string, string | false> | undefined;
} & {
path?: string | undefined;
};
output:
@@ -831,6 +850,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';
+471
View File
@@ -0,0 +1,471 @@
/*
* 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: '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: '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(),
);
});
});
+47 -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,57 @@ 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.record(z.string(), z.string().or(z.literal(false))).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> = {
...defaultEntityContentGroups,
...config.groups,
};
// 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 +130,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;
};
+2
View File
@@ -6468,6 +6468,7 @@ __metadata:
"@testing-library/user-event": ^14.0.0
"@types/pluralize": ^0.0.33
"@types/react": ^18.0.0
classnames: ^2.3.1
dataloader: ^2.0.0
expiry-map: ^2.0.0
history: ^5.0.0
@@ -6475,6 +6476,7 @@ __metadata:
pluralize: ^8.0.0
react: ^18.0.2
react-dom: ^18.0.2
react-helmet: 6.1.0
react-router-dom: ^6.3.0
react-use: ^17.2.4
swr: ^2.2.5