Merge pull request #28701 from backstage/camilaibs/nfs-entity-content-groups
[NFS] Entity Page Content Groups
This commit is contained in:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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';
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 />);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user