diff --git a/.changeset/brave-ears-bow.md b/.changeset/brave-ears-bow.md
new file mode 100644
index 0000000000..f89f4ebacf
--- /dev/null
+++ b/.changeset/brave-ears-bow.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-catalog': minor
+---
+
+Now a custom entity page header can be passed as input to the default entity page.
diff --git a/.changeset/nervous-cups-happen.md b/.changeset/nervous-cups-happen.md
new file mode 100644
index 0000000000..f42933dd6c
--- /dev/null
+++ b/.changeset/nervous-cups-happen.md
@@ -0,0 +1,16 @@
+---
+'@backstage/plugin-catalog-react': minor
+---
+
+Introduces a new `EntityHeaderBlueprint` that allows you to override the default entity page header.
+
+```jsx
+import { EntityHeaderBlueprint } from '@backstage/plugin-catalog-react/alpha';
+
+EntityHeaderBlueprint.make({
+ name: 'my-default-header',
+ params: {
+ loader: () => import('./MyDefaultHeader').then(m => ),
+ },
+});
+```
diff --git a/packages/app-next/app-config.yaml b/packages/app-next/app-config.yaml
index 8129ab87f7..9d2bcf198a 100644
--- a/packages/app-next/app-config.yaml
+++ b/packages/app-next/app-config.yaml
@@ -23,7 +23,7 @@ app:
# - development: false
# example overriding a default group title
- documentation:
- title: Docs 2
+ title: Docs
- deployment:
title: Deployments
# example adding a new group
diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md
index f495a043cc..6c7a31c54f 100644
--- a/plugins/catalog-react/report-alpha.api.md
+++ b/plugins/catalog-react/report-alpha.api.md
@@ -321,6 +321,32 @@ export interface EntityContentLayoutProps {
}>;
}
+// @alpha (undocumented)
+export const EntityHeaderBlueprint: ExtensionBlueprint<{
+ kind: 'entity-header';
+ name: undefined;
+ params: {
+ loader: () => Promise;
+ };
+ output: ConfigurableExtensionDataRef<
+ JSX_2.Element,
+ 'core.reactElement',
+ {
+ optional: true;
+ }
+ >;
+ inputs: {};
+ config: {};
+ configInput: {};
+ dataRefs: {
+ element: ConfigurableExtensionDataRef<
+ JSX_2.Element,
+ 'core.reactElement',
+ {}
+ >;
+ };
+}>;
+
// @alpha (undocumented)
export type EntityPredicate =
| EntityPredicateExpression
diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityHeaderBlueprint.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityHeaderBlueprint.tsx
new file mode 100644
index 0000000000..431197e753
--- /dev/null
+++ b/plugins/catalog-react/src/alpha/blueprints/EntityHeaderBlueprint.tsx
@@ -0,0 +1,44 @@
+/*
+ * 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 {
+ createExtensionBlueprint,
+ coreExtensionData,
+ ExtensionBoundary,
+} from '@backstage/frontend-plugin-api';
+
+/** @alpha */
+export const EntityHeaderBlueprint = createExtensionBlueprint({
+ kind: 'entity-header',
+ attachTo: { id: 'page:catalog/entity', input: 'header' },
+ dataRefs: {
+ element: coreExtensionData.reactElement,
+ },
+ output: [coreExtensionData.reactElement.optional()],
+ *factory(
+ params: {
+ loader: () => Promise;
+ },
+ { node },
+ ) {
+ const { loader } = params;
+ if (loader) {
+ yield coreExtensionData.reactElement(
+ ExtensionBoundary.lazy(node, loader),
+ );
+ }
+ },
+});
diff --git a/plugins/catalog-react/src/alpha/blueprints/index.ts b/plugins/catalog-react/src/alpha/blueprints/index.ts
index 9cde440819..10e339c44d 100644
--- a/plugins/catalog-react/src/alpha/blueprints/index.ts
+++ b/plugins/catalog-react/src/alpha/blueprints/index.ts
@@ -19,5 +19,6 @@ export {
EntityContentLayoutBlueprint,
type EntityContentLayoutProps,
} from './EntityContentLayoutBlueprint';
+export { EntityHeaderBlueprint } from './EntityHeaderBlueprint';
export { defaultEntityContentGroups } from './extensionData';
export type { EntityCardType } from './extensionData';
diff --git a/plugins/catalog/report-alpha.api.md b/plugins/catalog/report-alpha.api.md
index 3732f3355a..113fa5b6a1 100644
--- a/plugins/catalog/report-alpha.api.md
+++ b/plugins/catalog/report-alpha.api.md
@@ -972,6 +972,19 @@ const _default: FrontendPlugin<
}
>;
inputs: {
+ header: ExtensionInput<
+ ConfigurableExtensionDataRef<
+ JSX_2.Element,
+ 'core.reactElement',
+ {
+ optional: true;
+ }
+ >,
+ {
+ singleton: true;
+ optional: true;
+ }
+ >;
contents: ExtensionInput<
| ConfigurableExtensionDataRef
| ConfigurableExtensionDataRef
diff --git a/plugins/catalog/src/alpha/components/EntityHeader/EntityHeader.tsx b/plugins/catalog/src/alpha/components/EntityHeader/EntityHeader.tsx
new file mode 100644
index 0000000000..a8d9773e28
--- /dev/null
+++ b/plugins/catalog/src/alpha/components/EntityHeader/EntityHeader.tsx
@@ -0,0 +1,308 @@
+/*
+ * 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,
+} 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;
+}
+
+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() {
+ const { entity } = useAsyncEntity();
+ const { kind, namespace, name } = useRouteRefParams(entityRouteRef);
+ const { headerTitle: title } = headerProps(kind, namespace, name, entity);
+ return (
+
+
+ {entity ? : title}
+
+ {entity && }
+
+ );
+}
+
+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 ? (
+
+ {ancestorEntity && (
+
+ )}
+
+ {name}
+
+ ) : 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;
+ subtitle?: ReactNode;
+}) {
+ const {
+ UNSTABLE_extraContextMenuItems,
+ UNSTABLE_contextMenuOptions,
+ parentEntityRelations,
+ title,
+ 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 cleanUpAfterUnregisterConfirmation = 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 (
+ }
+ subtitle={
+ subtitle ?? (
+
+ )
+ }
+ >
+ {entity && (
+ <>
+
+
+ ['initialTab']) || undefined
+ }
+ open={inspectDialogOpen}
+ onClose={closeInspectEntityDialog}
+ onSelect={setInspectEntityDialogTab}
+ />
+
+ >
+ )}
+
+ );
+}
diff --git a/plugins/catalog/src/alpha/components/EntityHeader/index.ts b/plugins/catalog/src/alpha/components/EntityHeader/index.ts
new file mode 100644
index 0000000000..30e92df12c
--- /dev/null
+++ b/plugins/catalog/src/alpha/components/EntityHeader/index.ts
@@ -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';
diff --git a/plugins/catalog/src/alpha/components/EntityLabels/EntityLabels.tsx b/plugins/catalog/src/alpha/components/EntityLabels/EntityLabels.tsx
index b40d82d279..235bed210b 100644
--- a/plugins/catalog/src/alpha/components/EntityLabels/EntityLabels.tsx
+++ b/plugins/catalog/src/alpha/components/EntityLabels/EntityLabels.tsx
@@ -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;
diff --git a/plugins/catalog/src/alpha/components/EntityLabels/index.ts b/plugins/catalog/src/alpha/components/EntityLabels/index.ts
new file mode 100644
index 0000000000..53c8fc0b82
--- /dev/null
+++ b/plugins/catalog/src/alpha/components/EntityLabels/index.ts
@@ -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';
diff --git a/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx b/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx
index 1606fea377..80ff688715 100644
--- a/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx
+++ b/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx
@@ -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 (
- }
- pageTitleOverride={headerTitle}
- type={headerType}
- subtitle={
- parentEntity && (
-
- {ancestorEntity && (
-
- )}
-
- {name}
-
- )
- }
- >
- {entity && (
- <>
-
- setConfirmationDialogOpen(true)}
- onInspectEntity={() => setSearchParams('inspect')}
- />
- >
- )}
-
+ {header ?? (
+
+ )}
{loading && }
@@ -333,27 +173,6 @@ export const EntityLayout = (props: EntityLayoutProps) => {
)}
)}
-
- {showInspectTab && (
- ['initialTab']) || undefined
- }
- onSelect={newTab => setSearchParams(`inspect=${newTab}`)}
- open
- onClose={() => setSearchParams()}
- />
- )}
-
- setConfirmationDialogOpen(false)}
- />
);
};
diff --git a/plugins/catalog/src/alpha/components/EntityLayout/EntityLayoutTitle.tsx b/plugins/catalog/src/alpha/components/EntityLayout/EntityLayoutTitle.tsx
deleted file mode 100644
index 6b7024d642..0000000000
--- a/plugins/catalog/src/alpha/components/EntityLayout/EntityLayoutTitle.tsx
+++ /dev/null
@@ -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 (
-
-
- {entity ? : title}
-
- {entity && }
-
- );
-}
diff --git a/plugins/catalog/src/alpha/pages.test.tsx b/plugins/catalog/src/alpha/pages.test.tsx
index 90340f3ebe..7c548dc1f8 100644
--- a/plugins/catalog/src/alpha/pages.test.tsx
+++ b/plugins/catalog/src/alpha/pages.test.tsx
@@ -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',
@@ -138,372 +141,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(
-
- {tester.reactElement()}
- ,
- {
- 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(
+
+ {tester.reactElement()}
+ ,
+ {
+ config: {
+ app: {
+ title: 'Custom app',
},
- ],
- },
- },
- )
- .add(techdocsEntityContent)
- .add(apidocsEntityContent);
-
- await renderInTestApp(
-
- {tester.reactElement()}
- ,
- {
- 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(
-
- {tester.reactElement()}
- ,
- {
- 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(
+
+ {tester.reactElement()}
+ ,
+ {
+ 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(
+
+ {tester.reactElement()}
+ ,
+ {
+ 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(
+
+ {tester.reactElement()}
+ ,
+ {
+ 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(
+
+ {tester.reactElement()}
+ ,
+ {
+ 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(
+
+ {tester.reactElement()}
+ ,
+ {
+ 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(
+
+ {tester.reactElement()}
+ ,
+ {
+ 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(
+
+ {tester.reactElement()}
+ ,
+ {
+ 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 () => (
+
+ ),
},
});
- await renderInTestApp(
-
- {tester.reactElement()}
- ,
- {
- config: {
- app: {
- title: 'Custom app',
+ const tester = createExtensionTester(
+ Object.assign({ namespace: 'catalog' }, catalogEntityPage),
+ ).add(customEntityHeader);
+
+ await renderInTestApp(
+
+ {tester.reactElement()}
+ ,
+ {
+ 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(
-
- {tester.reactElement()}
- ,
- {
- 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(
-
- {tester.reactElement()}
- ,
- {
- 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(
-
- {tester.reactElement()}
- ,
- {
- 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(),
+ );
+ });
});
});
diff --git a/plugins/catalog/src/alpha/pages.tsx b/plugins/catalog/src/alpha/pages.tsx
index 205426bd59..977ca41118 100644
--- a/plugins/catalog/src/alpha/pages.tsx
+++ b/plugins/catalog/src/alpha/pages.tsx
@@ -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,10 @@ export const catalogPage = PageBlueprint.makeWithOverrides({
export const catalogEntityPage = PageBlueprint.makeWithOverrides({
name: 'entity',
inputs: {
+ header: createExtensionInput(
+ [EntityHeaderBlueprint.dataRefs.element.optional()],
+ { singleton: true, optional: true },
+ ),
contents: createExtensionInput([
coreExtensionData.reactElement,
coreExtensionData.routePath,
@@ -88,6 +94,10 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({
{ title: string; items: Array<(typeof inputs.contents)[0]> }
>;
+ const header = inputs.header?.get(
+ EntityHeaderBlueprint.dataRefs.element,
+ ) ?? ;
+
let groups = Object.entries(defaultEntityContentGroups).reduce(
(rest, group) => {
const [groupId, groupValue] = group;
@@ -125,7 +135,7 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({
const Component = () => {
return (
-
+
{Object.values(groups).flatMap(({ title, items }) =>
items.map(output => (