From 247a40b9a5dc679e25af2873f0748cda1dc5dfed Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Fri, 14 Feb 2025 22:53:59 +0100 Subject: [PATCH 1/2] feat: create entity page header extension Signed-off-by: Camila Belo --- .changeset/brave-ears-bow.md | 5 + .changeset/nervous-cups-happen.md | 96 +++ plugins/catalog-react/report-alpha.api.md | 97 +++ .../blueprints/EntityHeaderBlueprint.tsx | 101 +++ .../src/alpha/blueprints/index.ts | 1 + plugins/catalog/report-alpha.api.md | 45 + .../components/EntityHeader/EntityHeader.tsx | 321 +++++++ .../alpha/components/EntityHeader/index.ts | 17 + .../components/EntityLabels/EntityLabels.tsx | 2 +- .../alpha/components/EntityLabels/index.ts | 17 + .../components/EntityLayout/EntityLayout.tsx | 225 +---- .../EntityLayout/EntityLayoutTitle.tsx | 45 - plugins/catalog/src/alpha/pages.test.tsx | 796 ++++++++++-------- plugins/catalog/src/alpha/pages.tsx | 34 +- 14 files changed, 1199 insertions(+), 603 deletions(-) create mode 100644 .changeset/brave-ears-bow.md create mode 100644 .changeset/nervous-cups-happen.md create mode 100644 plugins/catalog-react/src/alpha/blueprints/EntityHeaderBlueprint.tsx create mode 100644 plugins/catalog/src/alpha/components/EntityHeader/EntityHeader.tsx create mode 100644 plugins/catalog/src/alpha/components/EntityHeader/index.ts create mode 100644 plugins/catalog/src/alpha/components/EntityLabels/index.ts delete mode 100644 plugins/catalog/src/alpha/components/EntityLayout/EntityLayoutTitle.tsx diff --git a/.changeset/brave-ears-bow.md b/.changeset/brave-ears-bow.md new file mode 100644 index 0000000000..49fd16dedc --- /dev/null +++ b/.changeset/brave-ears-bow.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': minor +--- + +Now multiple entity page headers can be configured based on an entity filter. diff --git a/.changeset/nervous-cups-happen.md b/.changeset/nervous-cups-happen.md new file mode 100644 index 0000000000..40031a2170 --- /dev/null +++ b/.changeset/nervous-cups-happen.md @@ -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 ( + + + + + + ); +} + +EntityHeaderBlueprint.make({ + name: 'my-default-header', + params: { + // The `FavoriteEntity` icon button is added by default + // You can also completely override the default title + // title: + title: { actions: [] }, + // A subtitle element is also supported. + // subtitle: + }, +}); +``` + +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 => ), + }, +}); +``` + +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 => ), +}); +``` + +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' +``` diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index f495a043cc..baf186897a 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -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; + } + | { + 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 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..5f62dea47c --- /dev/null +++ b/plugins/catalog-react/src/alpha/blueprints/EntityHeaderBlueprint.tsx @@ -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().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; + } + | { + 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); + } + }, +}); 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..25b3aa2e8a 100644 --- a/plugins/catalog/report-alpha.api.md +++ b/plugins/catalog/report-alpha.api.md @@ -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 | 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..fe3aeef6d3 --- /dev/null +++ b/plugins/catalog/src/alpha/components/EntityHeader/EntityHeader.tsx @@ -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 ( + + + {entity ? : title} + + {entity && } + {actions?.map((action, index) => ( + {action} + ))} + + ); +} + +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 | { 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 ( +
: title + } + 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..567a586e3d 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', @@ -114,6 +117,7 @@ describe('Index page', () => { params: { defaultPath: '/overview', defaultTitle: 'Overview', + defaultGroup: 'documentation', loader: async () =>
Mock Overview content
, }, }); @@ -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( - - {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 () => ( +
+

Custom header

+
+ ), }, }); - 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..84eccae3b3 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,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, + ) ?? ( + + ); + 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 ( - - + + {Object.values(groups).flatMap(({ title, items }) => items.map(output => ( Date: Fri, 28 Feb 2025 12:13:43 +0100 Subject: [PATCH 2/2] refactor: keep only the ability of overriding a defautl header for now Signed-off-by: Camila Belo --- .changeset/brave-ears-bow.md | 2 +- .changeset/nervous-cups-happen.md | 82 +--------------- packages/app-next/app-config.yaml | 2 +- plugins/catalog-react/report-alpha.api.md | 95 +++---------------- .../blueprints/EntityHeaderBlueprint.tsx | 75 ++------------- plugins/catalog/report-alpha.api.md | 52 ++-------- .../components/EntityHeader/EntityHeader.tsx | 25 ++--- plugins/catalog/src/alpha/pages.test.tsx | 1 - plugins/catalog/src/alpha/pages.tsx | 40 ++------ 9 files changed, 50 insertions(+), 324 deletions(-) diff --git a/.changeset/brave-ears-bow.md b/.changeset/brave-ears-bow.md index 49fd16dedc..f89f4ebacf 100644 --- a/.changeset/brave-ears-bow.md +++ b/.changeset/brave-ears-bow.md @@ -2,4 +2,4 @@ '@backstage/plugin-catalog': minor --- -Now multiple entity page headers can be configured based on an entity filter. +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 index 40031a2170..f42933dd6c 100644 --- a/.changeset/nervous-cups-happen.md +++ b/.changeset/nervous-cups-happen.md @@ -2,52 +2,7 @@ '@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 ( - - - - - - ); -} - -EntityHeaderBlueprint.make({ - name: 'my-default-header', - params: { - // The `FavoriteEntity` icon button is added by default - // You can also completely override the default title - // title: - title: { actions: [] }, - // A subtitle element is also supported. - // subtitle: - }, -}); -``` - -Setting up a completely different default header component: +Introduces a new `EntityHeaderBlueprint` that allows you to override the default entity page header. ```jsx import { EntityHeaderBlueprint } from '@backstage/plugin-catalog-react/alpha'; @@ -59,38 +14,3 @@ EntityHeaderBlueprint.make({ }, }); ``` - -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 => ), -}); -``` - -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' -``` 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 baf186897a..6c7a31c54f 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -14,7 +14,6 @@ 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'; @@ -326,95 +325,25 @@ export interface EntityContentLayoutProps { export const EntityHeaderBlueprint: ExtensionBlueprint<{ kind: 'entity-header'; name: undefined; - params: - | { - defaultFilter?: string | ((entity: Entity) => boolean) | undefined; - loader: () => Promise; - } - | { - 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; - } - >; + params: { + loader: () => Promise; + }; + output: ConfigurableExtensionDataRef< + JSX_2.Element, + 'core.reactElement', + { + optional: true; + } + >; inputs: {}; - config: { - filter: string | undefined; - }; - configInput: { - filter?: string | undefined; - }; + config: {}; + configInput: {}; 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', - {} - >; }; }>; diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityHeaderBlueprint.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityHeaderBlueprint.tsx index 5f62dea47c..431197e753 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityHeaderBlueprint.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityHeaderBlueprint.tsx @@ -14,88 +14,31 @@ * 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().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(), - ], + attachTo: { id: 'page:catalog/entity', input: 'header' }, dataRefs: { - filterFunction: entityFilterFunctionDataRef, - filterExpression: entityFilterExpressionDataRef, element: coreExtensionData.reactElement, - title: entityHeaderTitleActionsDataRef, - subtitle: entityHeaderSubtitleDataRef, - }, - config: { - schema: { - filter: z => z.string().optional(), - }, }, + output: [coreExtensionData.reactElement.optional()], *factory( - params: - | { - defaultFilter?: - | typeof entityFilterFunctionDataRef.T - | typeof entityFilterExpressionDataRef.T; - loader: () => Promise; - } - | { - defaultFilter?: - | typeof entityFilterFunctionDataRef.T - | typeof entityFilterExpressionDataRef.T; - title?: ReactNode | { actions: ReactNode[] }; - subtitle?: ReactNode; - }, - { config, node }, + params: { + loader: () => Promise; + }, + { 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) { + const { loader } = params; + if (loader) { yield coreExtensionData.reactElement( - ExtensionBoundary.lazy(node, params.loader), + ExtensionBoundary.lazy(node, loader), ); } - - if ('title' in params && params.title) { - yield entityHeaderTitleActionsDataRef(params.title); - } - - if ('subtitle' in params && params.subtitle) { - yield entityHeaderSubtitleDataRef(params.subtitle); - } }, }); diff --git a/plugins/catalog/report-alpha.api.md b/plugins/catalog/report-alpha.api.md index 25b3aa2e8a..113fa5b6a1 100644 --- a/plugins/catalog/report-alpha.api.md +++ b/plugins/catalog/report-alpha.api.md @@ -20,7 +20,6 @@ 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'; @@ -973,48 +972,17 @@ 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; - } - >, + header: ExtensionInput< + ConfigurableExtensionDataRef< + JSX_2.Element, + 'core.reactElement', + { + optional: true; + } + >, { - singleton: false; - optional: false; + singleton: true; + optional: true; } >; contents: ExtensionInput< diff --git a/plugins/catalog/src/alpha/components/EntityHeader/EntityHeader.tsx b/plugins/catalog/src/alpha/components/EntityHeader/EntityHeader.tsx index fe3aeef6d3..a8d9773e28 100644 --- a/plugins/catalog/src/alpha/components/EntityHeader/EntityHeader.tsx +++ b/plugins/catalog/src/alpha/components/EntityHeader/EntityHeader.tsx @@ -20,7 +20,6 @@ import React, { useEffect, ComponentProps, ReactNode, - Fragment, } from 'react'; import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'; import useAsync from 'react-use/esm/useAsync'; @@ -98,12 +97,6 @@ function findParentRelation( 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, @@ -119,8 +112,7 @@ const useStyles = makeStyles(theme => ({ }, })); -function EntityHeaderTitle(props: { actions?: ReactNode[] }) { - const { actions } = props; +function EntityHeaderTitle() { const { entity } = useAsyncEntity(); const { kind, namespace, name } = useRouteRefParams(entityRouteRef); const { headerTitle: title } = headerProps(kind, namespace, name, entity); @@ -135,9 +127,6 @@ function EntityHeaderTitle(props: { actions?: ReactNode[] }) { {entity ? : title} {entity && } - {actions?.map((action, index) => ( - {action} - ))} ); } @@ -200,14 +189,14 @@ export function EntityHeader(props: { * It adds breadcrumbs in the Entity page to enhance user navigation and context awareness. */ parentEntityRelations?: string[]; - title?: ReactNode | { actions: ReactNode[] }; + title?: ReactNode; subtitle?: ReactNode; }) { const { UNSTABLE_extraContextMenuItems, UNSTABLE_contextMenuOptions, parentEntityRelations, - title = { actions: [] }, + title, subtitle, } = props; const { entity } = useAsyncEntity(); @@ -236,7 +225,7 @@ export function EntityHeader(props: { [setConfirmationDialogOpen], ); - const cleanUpAfterUnregiterConfirmation = useCallback(async () => { + const cleanUpAfterUnregisterConfirmation = useCallback(async () => { setConfirmationDialogOpen(false); navigate( unregisterRedirectRoute ? unregisterRedirectRoute() : catalogRoute(), @@ -279,9 +268,7 @@ export function EntityHeader(props: {
: title - } + title={title ?? } subtitle={ subtitle ?? ( @@ -312,7 +299,7 @@ export function EntityHeader(props: { entity={entity!} open={confirmationDialogOpen} onClose={closeUnregisterEntityDialog} - onConfirm={cleanUpAfterUnregiterConfirmation} + onConfirm={cleanUpAfterUnregisterConfirmation} /> )} diff --git a/plugins/catalog/src/alpha/pages.test.tsx b/plugins/catalog/src/alpha/pages.test.tsx index 567a586e3d..7c548dc1f8 100644 --- a/plugins/catalog/src/alpha/pages.test.tsx +++ b/plugins/catalog/src/alpha/pages.test.tsx @@ -117,7 +117,6 @@ describe('Entity page', () => { params: { defaultPath: '/overview', defaultTitle: 'Overview', - defaultGroup: 'documentation', loader: async () =>
Mock Overview content
, }, }); diff --git a/plugins/catalog/src/alpha/pages.tsx b/plugins/catalog/src/alpha/pages.tsx index 84eccae3b3..977ca41118 100644 --- a/plugins/catalog/src/alpha/pages.tsx +++ b/plugins/catalog/src/alpha/pages.tsx @@ -60,13 +60,10 @@ 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(), - ]), + header: createExtensionInput( + [EntityHeaderBlueprint.dataRefs.element.optional()], + { singleton: true, optional: true }, + ), contents: createExtensionInput([ coreExtensionData.reactElement, coreExtensionData.routePath, @@ -90,24 +87,6 @@ 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, - ) ?? ( - - ); - return { - filter: buildFilterFn( - header.get(EntityHeaderBlueprint.dataRefs.filterFunction), - header.get(EntityHeaderBlueprint.dataRefs.filterExpression), - ), - element, - }; - }); - const { EntityLayout } = await import('./components/EntityLayout'); type Groups = Record< @@ -115,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; @@ -150,12 +133,9 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({ } const Component = () => { - const { entity, ...rest } = useEntityFromUrl(); - const header = headers.find(({ filter }) => entity && filter(entity)); - return ( - - + + {Object.values(groups).flatMap(({ title, items }) => items.map(output => (