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