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 () => ( +
+

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..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 => (