diff --git a/.changeset/friendly-garlics-explain.md b/.changeset/friendly-garlics-explain.md new file mode 100644 index 0000000000..3bf68628b6 --- /dev/null +++ b/.changeset/friendly-garlics-explain.md @@ -0,0 +1,50 @@ +--- +'@backstage/plugin-catalog-react': minor +--- + +Add a new `defaultGroup` parameter to the `EntityContentBlueprint`, here are usage examples: + +Set a default group while creating the extension: + +```diff +const entityKubernetesContent = EntityContentBlueprint.make({ + name: 'kubernetes', + params: { + defaultPath: '/kubernetes', + defaultTitle: 'Kubernetes', ++ defaultGroup: 'deployment', + filter: 'kind:component,resource', + loader: () => + import('./KubernetesContentPage').then(m => + compatWrapper(), + ), + }, +}); +``` + +Disassociate an entity content from a default group: + +```diff +# app-config.yaml +app: + extensions: + # Entity page content +- - entity-content:kubernetes/kubernetes ++ - entity-content:kubernetes/kubernetes: ++ config: ++ group: false +``` + +Associate an entity content with a different default or custom group than the one defined in code when the extension was created: + +```diff +# app-config.yaml +app: + extensions: + # Entity page content +- - entity-content:kubernetes/kubernetes ++ - entity-content:kubernetes/kubernetes: ++ config: ++ group: custom # associating this extension with a custom group id, the group should have previously been created via entity page configuration + +``` diff --git a/.changeset/thin-steaks-trade.md b/.changeset/thin-steaks-trade.md new file mode 100644 index 0000000000..20c8446f86 --- /dev/null +++ b/.changeset/thin-steaks-trade.md @@ -0,0 +1,45 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Update the default entity page extension component to support grouping multiple entity content items in the same tab. + +Disable a default group via configuration: + +```diff +# app-config.yaml +app: + extensions: + # Pages ++ - page:catalog/entity: ++ config: ++ groups: ++ deployment: false +``` + +Change a default group title via configuration: + +```diff +# app-config.yaml +app: + extensions: + # Pages ++ - page:catalog/entity: ++ config: ++ groups: ++ deployment: Infrastructure # this is overriding the default group title +``` + +Create a custom entity content group via configuration: + +```diff +# app-config.yaml +app: + extensions: + # Pages ++ - page:catalog/entity: ++ config: ++ groups: ++ # : ++ custom: Custom +``` diff --git a/.changeset/young-gifts-know.md b/.changeset/young-gifts-know.md new file mode 100644 index 0000000000..5e10ac74a3 --- /dev/null +++ b/.changeset/young-gifts-know.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-kubernetes': patch +--- + +Set deployment as the default group of Kubernetes entity content. It is just an example and shouldn't cause any visual difference since entity page tabs with just one entity content appear as normal tabs. diff --git a/packages/app-next/app-config.yaml b/packages/app-next/app-config.yaml index f6d0174ca1..ee3007a1a0 100644 --- a/packages/app-next/app-config.yaml +++ b/packages/app-next/app-config.yaml @@ -12,6 +12,18 @@ app: # - apis.plugin.graphiql.browse.gitlab: true # - graphiql-endpoint:graphiql/gitlab: true + # Pages + - page:catalog/entity: + config: + groups: + # example disabling a default group + development: false + # example overriding a default group title + documentation: Docs + # example adding a new group + # Format <GROUP_ID>: <GROUP_TITLE> + custom: Custom + # Entity page cards - entity-card:catalog/about - entity-card:catalog/labels @@ -27,23 +39,33 @@ app: - entity-card:api-docs/provided-apis - entity-card:api-docs/providing-components - entity-card:api-docs/consuming-components - - # - entity-card:azure-devops/readme - # Entity page content - - entity-content:api-docs/definition - - entity-content:api-docs/apis - - entity-content:techdocs - # - entity-content:azure-devops/pipelines - # - entity-content:azure-devops/pull-requests - # - entity-content:azure-devops/git-tags - # Org Plugin - entity-card:org/group-profile - entity-card:org/members-list - entity-card:org/ownership - entity-card:org/user-profile + # - entity-card:azure-devops/readme - - entity-content:kubernetes/kubernetes + # Entity page contents + - entity-content:catalog/overview: + config: + # associating with a custom group + group: custom + - entity-content:api-docs/definition + - entity-content:api-docs/apis: + config: + # example associating with a default group + group: documentation + - entity-content:techdocs: + config: + group: documentation + - entity-content:kubernetes/kubernetes: + config: + # example disassociating with a default group + group: false + # - entity-content:azure-devops/pipelines + # - entity-content:azure-devops/pull-requests + # - entity-content:azure-devops/git-tags # scmAuthExtension: >- # createScmAuthExtension({ diff --git a/plugins/api-docs/report-alpha.api.md b/plugins/api-docs/report-alpha.api.md index 820978b877..c1529c3028 100644 --- a/plugins/api-docs/report-alpha.api.md +++ b/plugins/api-docs/report-alpha.api.md @@ -320,11 +320,13 @@ const _default: FrontendPlugin< path: string | undefined; title: string | undefined; filter: string | undefined; + group: string | false | undefined; }; configInput: { filter?: string | undefined; title?: string | undefined; path?: string | undefined; + group?: string | false | undefined; }; output: | ConfigurableExtensionDataRef< @@ -358,12 +360,25 @@ const _default: FrontendPlugin< { optional: true; } + > + | ConfigurableExtensionDataRef< + string | false, + 'catalog.entity-content-group', + { + optional: true; + } >; inputs: {}; params: { loader: () => Promise<JSX.Element>; defaultPath: string; defaultTitle: string; + defaultGroup?: + | 'documentation' + | 'development' + | 'deployment' + | 'observability' + | undefined; routeRef?: RouteRef<AnyRouteRefParams> | undefined; filter?: string | ((entity: Entity) => boolean) | undefined; }; @@ -375,11 +390,13 @@ const _default: FrontendPlugin< path: string | undefined; title: string | undefined; filter: string | undefined; + group: string | false | undefined; }; configInput: { filter?: string | undefined; title?: string | undefined; path?: string | undefined; + group?: string | false | undefined; }; output: | ConfigurableExtensionDataRef< @@ -413,12 +430,25 @@ const _default: FrontendPlugin< { optional: true; } + > + | ConfigurableExtensionDataRef< + string | false, + 'catalog.entity-content-group', + { + optional: true; + } >; inputs: {}; params: { loader: () => Promise<JSX.Element>; defaultPath: string; defaultTitle: string; + defaultGroup?: + | 'documentation' + | 'development' + | 'deployment' + | 'observability' + | undefined; routeRef?: RouteRef<AnyRouteRefParams> | undefined; filter?: string | ((entity: Entity) => boolean) | undefined; }; diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index 516d655967..259e6b4d4c 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -98,6 +98,14 @@ export function convertLegacyEntityContentExtension( }, ): ExtensionDefinition; +// @alpha +export const defaultEntityContentGroups: { + documentation: string; + development: string; + deployment: string; + observability: string; +}; + // @alpha export const EntityCardBlueprint: ExtensionBlueprint<{ kind: 'entity-card'; @@ -151,6 +159,12 @@ export const EntityContentBlueprint: ExtensionBlueprint<{ loader: () => Promise<JSX.Element>; defaultPath: string; defaultTitle: string; + defaultGroup?: + | 'documentation' + | 'development' + | 'deployment' + | 'observability' + | undefined; routeRef?: RouteRef<AnyRouteRefParams> | undefined; filter?: string | ((entity: Entity) => boolean) | undefined; }; @@ -178,17 +192,26 @@ export const EntityContentBlueprint: ExtensionBlueprint<{ { optional: true; } + > + | ConfigurableExtensionDataRef< + string | false, + 'catalog.entity-content-group', + { + optional: true; + } >; inputs: {}; config: { path: string | undefined; title: string | undefined; filter: string | undefined; + group: string | false | undefined; }; configInput: { filter?: string | undefined; title?: string | undefined; path?: string | undefined; + group?: string | false | undefined; }; dataRefs: { title: ConfigurableExtensionDataRef< @@ -206,6 +229,11 @@ export const EntityContentBlueprint: ExtensionBlueprint<{ 'catalog.entity-filter-expression', {} >; + group: ConfigurableExtensionDataRef< + string | false, + 'catalog.entity-content-group', + {} + >; }; }>; diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.test.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.test.tsx index 0b5873b6b3..15d0e3e1ab 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.test.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.test.tsx @@ -56,6 +56,17 @@ describe('EntityContentBlueprint', () => { "filter": { "type": "string", }, + "group": { + "anyOf": [ + { + "const": false, + "type": "boolean", + }, + { + "type": "string", + }, + ], + }, "path": { "type": "string", }, @@ -102,6 +113,15 @@ describe('EntityContentBlueprint', () => { "optional": [Function], "toString": [Function], }, + { + "$$type": "@backstage/ExtensionDataRef", + "config": { + "optional": true, + }, + "id": "catalog.entity-content-group", + "optional": [Function], + "toString": [Function], + }, ], "override": [Function], "toString": [Function], diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts index 54cd476a90..5e820e3b0a 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts @@ -24,6 +24,8 @@ import { entityContentTitleDataRef, entityFilterFunctionDataRef, entityFilterExpressionDataRef, + entityContentGroupDataRef, + defaultEntityContentGroups, } from './extensionData'; /** @@ -40,17 +42,20 @@ export const EntityContentBlueprint = createExtensionBlueprint({ coreExtensionData.routeRef.optional(), entityFilterFunctionDataRef.optional(), entityFilterExpressionDataRef.optional(), + entityContentGroupDataRef.optional(), ], dataRefs: { title: entityContentTitleDataRef, filterFunction: entityFilterFunctionDataRef, filterExpression: entityFilterExpressionDataRef, + group: entityContentGroupDataRef, }, config: { schema: { path: z => z.string().optional(), title: z => z.string().optional(), filter: z => z.string().optional(), + group: z => z.literal(false).or(z.string()).optional(), }, }, *factory( @@ -58,12 +63,14 @@ export const EntityContentBlueprint = createExtensionBlueprint({ loader, defaultPath, defaultTitle, + defaultGroup, filter, routeRef, }: { loader: () => Promise<JSX.Element>; defaultPath: string; defaultTitle: string; + defaultGroup?: keyof typeof defaultEntityContentGroups; routeRef?: RouteRef; filter?: | typeof entityFilterFunctionDataRef.T @@ -73,6 +80,7 @@ export const EntityContentBlueprint = createExtensionBlueprint({ ) { const path = config.path ?? defaultPath; const title = config.title ?? defaultTitle; + const group = config.group ?? defaultGroup; yield coreExtensionData.reactElement(ExtensionBoundary.lazy(node, loader)); @@ -91,5 +99,9 @@ export const EntityContentBlueprint = createExtensionBlueprint({ } else if (typeof filter === 'function') { yield entityFilterFunctionDataRef(filter); } + + if (group) { + yield entityContentGroupDataRef(group); + } }, }); diff --git a/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx b/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx index 09aac809cb..69bd4d47c3 100644 --- a/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx @@ -32,3 +32,21 @@ export const entityFilterExpressionDataRef = createExtensionDataRef<string>().with({ id: 'catalog.entity-filter-expression', }); + +/** + * @alpha + * Default entity content groups. + */ +export const defaultEntityContentGroups = { + documentation: 'Documentation', + development: 'Development', + deployment: 'Deployment', + observability: 'Observability', +}; + +/** @internal */ +export const entityContentGroupDataRef = createExtensionDataRef< + false | string +>().with({ + id: 'catalog.entity-content-group', +}); diff --git a/plugins/catalog-react/src/alpha/blueprints/index.ts b/plugins/catalog-react/src/alpha/blueprints/index.ts index dccaf28cd9..1a3b9941f7 100644 --- a/plugins/catalog-react/src/alpha/blueprints/index.ts +++ b/plugins/catalog-react/src/alpha/blueprints/index.ts @@ -15,3 +15,4 @@ */ export { EntityCardBlueprint } from './EntityCardBlueprint'; export { EntityContentBlueprint } from './EntityContentBlueprint'; +export { defaultEntityContentGroups } from './extensionData'; diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index 169b651e28..c341189755 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -77,11 +77,13 @@ "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.61", "@mui/utils": "^5.14.15", + "classnames": "^2.3.1", "dataloader": "^2.0.0", "expiry-map": "^2.0.0", "history": "^5.0.0", "lodash": "^4.17.21", "pluralize": "^8.0.0", + "react-helmet": "6.1.0", "react-use": "^17.2.4", "zen-observable": "^0.10.0" }, diff --git a/plugins/catalog/report-alpha.api.md b/plugins/catalog/report-alpha.api.md index bdd9240d53..64ed11928d 100644 --- a/plugins/catalog/report-alpha.api.md +++ b/plugins/catalog/report-alpha.api.md @@ -527,11 +527,13 @@ const _default: FrontendPlugin< path: string | undefined; title: string | undefined; filter: string | undefined; + group: string | false | undefined; }; configInput: { filter?: string | undefined; title?: string | undefined; path?: string | undefined; + group?: string | false | undefined; }; output: | ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}> @@ -561,6 +563,13 @@ const _default: FrontendPlugin< { optional: true; } + > + | ConfigurableExtensionDataRef< + string | false, + 'catalog.entity-content-group', + { + optional: true; + } >; inputs: { cards: ExtensionInput< @@ -591,6 +600,12 @@ const _default: FrontendPlugin< loader: () => Promise<JSX.Element>; defaultPath: string; defaultTitle: string; + defaultGroup?: + | 'documentation' + | 'development' + | 'deployment' + | 'observability' + | undefined; routeRef?: RouteRef<AnyRouteRefParams> | undefined; filter?: string | ((entity: Entity) => boolean) | undefined; }; @@ -787,9 +802,13 @@ const _default: FrontendPlugin< }>; 'page:catalog/entity': ExtensionDefinition<{ config: { + groups: Record<string, string | false> | undefined; + } & { path: string | undefined; }; configInput: { + groups?: Record<string, string | false> | undefined; + } & { path?: string | undefined; }; output: @@ -831,6 +850,13 @@ const _default: FrontendPlugin< { optional: true; } + > + | ConfigurableExtensionDataRef< + string | false, + 'catalog.entity-content-group', + { + optional: true; + } >, { singleton: false; diff --git a/plugins/catalog/src/alpha/components/EntityLabels/EntityLabels.tsx b/plugins/catalog/src/alpha/components/EntityLabels/EntityLabels.tsx new file mode 100644 index 0000000000..b40d82d279 --- /dev/null +++ b/plugins/catalog/src/alpha/components/EntityLabels/EntityLabels.tsx @@ -0,0 +1,58 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { HeaderLabel } from '@backstage/core-components'; +import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model'; +import { + EntityRefLinks, + getEntityRelations, +} from '@backstage/plugin-catalog-react'; +import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; +import { catalogTranslationRef } from '../../../alpha/translation'; + +type EntityLabelsProps = { + entity: Entity; +}; + +export function EntityLabels(props: EntityLabelsProps) { + const { entity } = props; + const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY); + const { t } = useTranslationRef(catalogTranslationRef); + return ( + <> + {ownedByRelations.length > 0 && ( + <HeaderLabel + label={t('entityLabels.ownerLabel')} + contentTypograpyRootComponent="p" + value={ + <EntityRefLinks + entityRefs={ownedByRelations} + defaultKind="Group" + color="inherit" + /> + } + /> + )} + {entity.spec?.lifecycle && ( + <HeaderLabel + label={t('entityLabels.lifecycleLabel')} + value={entity.spec.lifecycle?.toString()} + /> + )} + </> + ); +} diff --git a/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx b/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx new file mode 100644 index 0000000000..1606fea377 --- /dev/null +++ b/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx @@ -0,0 +1,361 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { ComponentProps, useEffect, useState } from 'react'; +import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; +import useAsync from 'react-use/esm/useAsync'; + +import { makeStyles } from '@material-ui/core/styles'; +import Alert from '@material-ui/lab/Alert'; + +import { + attachComponentData, + IconComponent, + useApi, + useElementFilter, + useRouteRef, + useRouteRefParams, +} from '@backstage/core-plugin-api'; +import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; +import { + Breadcrumbs, + Content, + Header, + Link, + Page, + Progress, + WarningPanel, +} from '@backstage/core-components'; +import { + DEFAULT_NAMESPACE, + Entity, + EntityRelation, +} from '@backstage/catalog-model'; +import { + catalogApiRef, + EntityRefLink, + entityRouteRef, + InspectEntityDialog, + UnregisterEntityDialog, + useAsyncEntity, +} from '@backstage/plugin-catalog-react'; + +import { catalogTranslationRef } from '../../../alpha/translation'; +import { rootRouteRef, unregisterRedirectRouteRef } from '../../../routes'; +import { EntityContextMenu } from '../../../components/EntityContextMenu/EntityContextMenu'; +import { EntityTabs } from '../EntityTabs'; +import { EntityLabels } from '../EntityLabels/EntityLabels'; +import { EntityLayoutTitle } from './EntityLayoutTitle'; + +export type EntityLayoutRouteProps = { + path: string; + title: string; + group: string; + children: JSX.Element; + if?: (entity: Entity) => boolean; +}; + +const dataKey = 'plugin.catalog.entityLayoutRoute'; +const Route: (props: EntityLayoutRouteProps) => null = () => null; +attachComponentData(Route, dataKey, true); +attachComponentData(Route, 'core.gatherMountPoints', true); // This causes all mount points that are discovered within this route to use the path of the route itself + +function headerProps( + paramKind: string | undefined, + paramNamespace: string | undefined, + paramName: string | undefined, + entity: Entity | undefined, +): { headerTitle: string; headerType: string } { + const kind = paramKind ?? entity?.kind ?? ''; + const namespace = paramNamespace ?? entity?.metadata.namespace ?? ''; + const name = + entity?.metadata.title ?? paramName ?? entity?.metadata.name ?? ''; + + return { + headerTitle: `${name}${ + namespace && namespace !== DEFAULT_NAMESPACE ? ` in ${namespace}` : '' + }`, + headerType: (() => { + let t = kind.toLocaleLowerCase('en-US'); + if (entity && entity.spec && 'type' in entity.spec) { + t += ' — '; + t += (entity.spec as { type: string }).type.toLocaleLowerCase('en-US'); + } + return t; + })(), + }; +} + +function findParentRelation( + entityRelations: EntityRelation[] = [], + relationTypes: string[] = [], +) { + for (const type of relationTypes) { + const foundRelation = entityRelations.find( + relation => relation.type === type, + ); + if (foundRelation) { + return foundRelation; // Return the first found relation and stop + } + } + return null; +} + +const useStyles = makeStyles(theme => ({ + breadcrumbs: { + color: theme.page.fontColor, + fontSize: theme.typography.caption.fontSize, + textTransform: 'uppercase', + marginTop: theme.spacing(1), + opacity: 0.8, + '& span ': { + color: theme.page.fontColor, + textDecoration: 'underline', + textUnderlineOffset: '3px', + }, + }, +})); + +// NOTE(freben): Intentionally not exported at this point, since it's part of +// the unstable extra context menu items concept below +interface ExtraContextMenuItem { + title: string; + Icon: IconComponent; + onClick: () => void; +} + +type VisibleType = 'visible' | 'hidden' | 'disable'; + +// NOTE(blam): Intentionally not exported at this point, since it's part of +// unstable context menu option, eg: disable the unregister entity menu +interface EntityContextMenuOptions { + disableUnregister: boolean | VisibleType; +} + +/** @public */ +export interface EntityLayoutProps { + UNSTABLE_extraContextMenuItems?: ExtraContextMenuItem[]; + UNSTABLE_contextMenuOptions?: EntityContextMenuOptions; + children?: React.ReactNode; + NotFoundComponent?: React.ReactNode; + /** + * An array of relation types used to determine the parent entities in the hierarchy. + * These relations are prioritized in the order provided, allowing for flexible + * navigation through entity relationships. + * + * For example, use relation types like `["partOf", "memberOf", "ownedBy"]` to define how the entity is related to + * its parents in the Entity Catalog. + * + * It adds breadcrumbs in the Entity page to enhance user navigation and context awareness. + */ + parentEntityRelations?: string[]; +} + +/** + * EntityLayout is a compound component, which allows you to define a layout for + * entities using a sub-navigation mechanism. + * + * Consists of two parts: EntityLayout and EntityLayout.Route + * + * @example + * ```jsx + * <EntityLayout> + * <EntityLayout.Route path="/example" title="Example tab"> + * <div>This is rendered under /example/anything-here route</div> + * </EntityLayout.Route> + * </EntityLayout> + * ``` + * + * @public + */ +export const EntityLayout = (props: EntityLayoutProps) => { + const { + UNSTABLE_extraContextMenuItems, + UNSTABLE_contextMenuOptions, + children, + NotFoundComponent, + parentEntityRelations, + } = props; + const classes = useStyles(); + const { kind, namespace, name } = useRouteRefParams(entityRouteRef); + const { entity, loading, error } = useAsyncEntity(); + const location = useLocation(); + + const routes = useElementFilter( + children, + elements => + elements + .selectByComponentData({ + key: dataKey, + withStrictError: + 'Child of EntityLayout must be an EntityLayout.Route', + }) + .getElements<EntityLayoutRouteProps>() // all nodes, element data, maintain structure or not? + .flatMap(({ props: elementProps }) => { + if (!entity) { + return []; + } + if (elementProps.if && !elementProps.if(entity)) { + return []; + } + return [ + { + path: elementProps.path, + title: elementProps.title, + group: elementProps.group, + children: elementProps.children, + }, + ]; + }), + [entity], + ); + + const { headerTitle, headerType } = headerProps( + kind, + namespace, + name, + entity, + ); + + const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false); + const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); + + const catalogRoute = useRouteRef(rootRouteRef); + const unregisterRedirectRoute = useRouteRef(unregisterRedirectRouteRef); + const { t } = useTranslationRef(catalogTranslationRef); + + const cleanUpAfterRemoval = async () => { + setConfirmationDialogOpen(false); + navigate( + unregisterRedirectRoute ? unregisterRedirectRoute() : catalogRoute(), + ); + }; + + const parentEntity = findParentRelation( + entity?.relations ?? [], + parentEntityRelations ?? [], + ); + + const catalogApi = useApi(catalogApiRef); + const { value: ancestorEntity } = useAsync(async () => { + if (parentEntity) { + return findParentRelation( + (await catalogApi.getEntityByRef(parentEntity?.targetRef))?.relations, + parentEntityRelations, + ); + } + return null; + }, [parentEntity]); + + // Make sure to close the dialog if the user clicks links in it that navigate + // to another entity. + useEffect(() => { + setConfirmationDialogOpen(false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [location.pathname]); + + const selectedInspectTab = searchParams.get('inspect'); + const showInspectTab = typeof selectedInspectTab === 'string'; + + return ( + <Page themeId={entity?.spec?.type?.toString() ?? 'home'}> + <Header + title={<EntityLayoutTitle title={headerTitle} entity={entity!} />} + pageTitleOverride={headerTitle} + type={headerType} + subtitle={ + parentEntity && ( + <Breadcrumbs separator=">" className={classes.breadcrumbs}> + {ancestorEntity && ( + <EntityRefLink + entityRef={ancestorEntity.targetRef} + disableTooltip + /> + )} + <EntityRefLink + entityRef={parentEntity.targetRef} + disableTooltip + /> + {name} + </Breadcrumbs> + ) + } + > + {entity && ( + <> + <EntityLabels entity={entity} /> + <EntityContextMenu + UNSTABLE_extraContextMenuItems={UNSTABLE_extraContextMenuItems} + UNSTABLE_contextMenuOptions={UNSTABLE_contextMenuOptions} + onUnregisterEntity={() => setConfirmationDialogOpen(true)} + onInspectEntity={() => setSearchParams('inspect')} + /> + </> + )} + </Header> + + {loading && <Progress />} + + {entity && <EntityTabs routes={routes} />} + + {error && ( + <Content> + <Alert severity="error">{error.toString()}</Alert> + </Content> + )} + + {!loading && !error && !entity && ( + <Content> + {NotFoundComponent ? ( + NotFoundComponent + ) : ( + <WarningPanel title={t('entityLabels.warningPanelTitle')}> + There is no {kind} with the requested{' '} + <Link to="https://backstage.io/docs/features/software-catalog/references"> + kind, namespace, and name + </Link> + . + </WarningPanel> + )} + </Content> + )} + + {showInspectTab && ( + <InspectEntityDialog + entity={entity!} + initialTab={ + (selectedInspectTab as ComponentProps< + typeof InspectEntityDialog + >['initialTab']) || undefined + } + onSelect={newTab => setSearchParams(`inspect=${newTab}`)} + open + onClose={() => setSearchParams()} + /> + )} + + <UnregisterEntityDialog + open={confirmationDialogOpen} + entity={entity!} + onConfirm={cleanUpAfterRemoval} + onClose={() => setConfirmationDialogOpen(false)} + /> + </Page> + ); +}; + +EntityLayout.Route = Route; diff --git a/plugins/catalog/src/alpha/components/EntityLayout/EntityLayoutTitle.tsx b/plugins/catalog/src/alpha/components/EntityLayout/EntityLayoutTitle.tsx new file mode 100644 index 0000000000..6b7024d642 --- /dev/null +++ b/plugins/catalog/src/alpha/components/EntityLayout/EntityLayoutTitle.tsx @@ -0,0 +1,45 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import Box from '@material-ui/core/Box'; +import { Entity } from '@backstage/catalog-model'; +import { + EntityDisplayName, + FavoriteEntity, +} from '@backstage/plugin-catalog-react'; + +type EntityLayoutTitleProps = { + title: string; + entity: Entity | undefined; +}; + +export function EntityLayoutTitle(props: EntityLayoutTitleProps) { + const { entity, title } = props; + return ( + <Box display="inline-flex" alignItems="center" height="1em" maxWidth="100%"> + <Box + component="span" + textOverflow="ellipsis" + whiteSpace="nowrap" + overflow="hidden" + > + {entity ? <EntityDisplayName entityRef={entity} hideIcon /> : title} + </Box> + {entity && <FavoriteEntity entity={entity} />} + </Box> + ); +} diff --git a/plugins/catalog/src/alpha/components/EntityLayout/index.ts b/plugins/catalog/src/alpha/components/EntityLayout/index.ts new file mode 100644 index 0000000000..a704043e1a --- /dev/null +++ b/plugins/catalog/src/alpha/components/EntityLayout/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 { EntityLayout } from './EntityLayout'; diff --git a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.tsx b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.tsx new file mode 100644 index 0000000000..5d37707ecf --- /dev/null +++ b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.tsx @@ -0,0 +1,107 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { useMemo } from 'react'; +import { Helmet } from 'react-helmet'; +import { matchRoutes, useParams, useRoutes } from 'react-router-dom'; +import { EntityTabsPanel } from './EntityTabsPanel'; +import { EntityTabsList } from './EntityTabsList'; + +type SubRoute = { + group: string; + path: string; + title: string; + children: JSX.Element; +}; + +export function useSelectedSubRoute(subRoutes: SubRoute[]): { + index: number; + route?: SubRoute; + element?: JSX.Element; +} { + const params = useParams(); + + const routes = subRoutes.map(({ path, children }) => ({ + caseSensitive: false, + path: `${path}/*`, + element: children, + })); + + // TODO: remove once react-router updated + const sortedRoutes = routes.sort((a, b) => + // remove "/*" symbols from path end before comparing + b.path.replace(/\/\*$/, '').localeCompare(a.path.replace(/\/\*$/, '')), + ); + + const element = useRoutes(sortedRoutes) ?? subRoutes[0]?.children; + + // TODO(Rugvip): Once we only support v6 stable we can always prefix + // This avoids having a double / prefix for react-router v6 beta, which in turn breaks + // the tab highlighting when using relative paths for the tabs. + let currentRoute = params['*'] ?? ''; + if (!currentRoute.startsWith('/')) { + currentRoute = `/${currentRoute}`; + } + + const [matchedRoute] = matchRoutes(sortedRoutes, currentRoute) ?? []; + const foundIndex = matchedRoute + ? subRoutes.findIndex(t => `${t.path}/*` === matchedRoute.route.path) + : 0; + + return { + index: foundIndex === -1 ? 0 : foundIndex, + element, + route: subRoutes[foundIndex] ?? subRoutes[0], + }; +} + +type EntityTabsProps = { + routes: SubRoute[]; +}; + +export function EntityTabs(props: EntityTabsProps) { + const { routes } = props; + + const { index, route, element } = useSelectedSubRoute(routes); + + const tabs = useMemo( + () => + routes.map(t => { + const { path, title, group } = t; + let to = path; + // Remove trailing /* + to = to.replace(/\/\*$/, ''); + // And remove leading / for relative navigation + to = to.replace(/^\//, ''); + return { + group, + id: path, + path: to, + label: title, + }; + }), + [routes], + ); + + return ( + <> + <EntityTabsList tabs={tabs} selectedIndex={index} /> + <EntityTabsPanel> + <Helmet title={route?.title} /> + {element} + </EntityTabsPanel> + </> + ); +} diff --git a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsGroup.tsx b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsGroup.tsx new file mode 100644 index 0000000000..9cd7c4ab23 --- /dev/null +++ b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsGroup.tsx @@ -0,0 +1,298 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { useState, MouseEvent, MouseEventHandler } from 'react'; +import { Link } from 'react-router-dom'; +import classnames from 'classnames'; + +import Typography from '@material-ui/core/Typography'; +import ButtonBase from '@material-ui/core/ButtonBase'; +import Popover from '@material-ui/core/Popover'; +import { TabProps, TabClassKey } from '@material-ui/core/Tab'; +import { capitalize } from '@material-ui/core/utils'; +import { createStyles, Theme, withStyles } from '@material-ui/core/styles'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; + +const styles = (theme: Theme) => + createStyles({ + /* Styles applied to the root element. */ + root: { + ...theme.typography.button, + maxWidth: 264, + minWidth: 72, + position: 'relative', + boxSizing: 'border-box', + minHeight: 48, + flexShrink: 0, + padding: '6px 12px', + [theme.breakpoints.up('sm')]: { + padding: '6px 24px', + }, + overflow: 'hidden', + whiteSpace: 'normal', + textAlign: 'center', + [theme.breakpoints.up('sm')]: { + minWidth: 160, + }, + }, + popInButton: { + width: '100%', + }, + defaultTab: { + ...theme.typography.caption, + padding: theme.spacing(3, 3), + textTransform: 'uppercase', + fontWeight: theme.typography.fontWeightBold, + color: theme.palette.text.secondary, + }, + /* Styles applied to the root element if both `icon` and `label` are provided. */ + labelIcon: { + minHeight: 72, + paddingTop: 9, + '& $wrapper > *:first-child': { + marginBottom: 6, + }, + }, + /* Styles applied to the root element if the parent [`Tabs`](/api/tabs/) has `textColor="inherit"`. */ + textColorInherit: { + color: 'inherit', + opacity: 0.7, + '&$selected': { + opacity: 1, + }, + '&$disabled': { + opacity: 0.5, + }, + }, + selectedButton: { + color: `${theme.palette.text.primary}`, + opacity: `${1}`, + }, + unselectedButton: { + color: `${theme.palette.text.secondary}`, + opacity: `${0.7}`, + }, + /* Styles applied to the root element if the parent [`Tabs`](/api/tabs/) has `textColor="primary"`. */ + textColorPrimary: { + color: theme.palette.text.secondary, + '&$selected': { + color: theme.palette.primary.main, + }, + '&$disabled': { + color: theme.palette.text.disabled, + }, + }, + /* Styles applied to the root element if the parent [`Tabs`](/api/tabs/) has `textColor="secondary"`. */ + textColorSecondary: { + color: theme.palette.text.secondary, + '&$selected': { + color: theme.palette.secondary.main, + }, + '&$disabled': { + color: theme.palette.text.disabled, + }, + }, + /* Pseudo-class applied to the root element if `selected={true}` (controlled by the Tabs component). */ + selected: {}, + /* Pseudo-class applied to the root element if `disabled={true}` (controlled by the Tabs component). */ + disabled: {}, + /* Styles applied to the root element if `fullWidth={true}` (controlled by the Tabs component). */ + fullWidth: { + flexShrink: 1, + flexGrow: 1, + flexBasis: 0, + maxWidth: 'none', + }, + /* Styles applied to the root element if `wrapped={true}`. */ + wrapped: { + fontSize: theme.typography.pxToRem(12), + lineHeight: 1.5, + }, + /* Styles applied to the `icon` and `label`'s wrapper element. */ + wrapper: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: '100%', + flexDirection: 'row', + }, + }); + +type EntityTabsGroupItem = { + id: string; + index: number; + label: string; + path: string; + group: string; +}; + +type EntityTabsGroupProps = TabProps & { + classes?: Partial<ReturnType<typeof styles>>; + indicator?: React.ReactNode; + highlightedButton?: number; + items: EntityTabsGroupItem[]; + onSelectTab: MouseEventHandler<HTMLAnchorElement>; +}; + +const Tab = React.forwardRef(function Tab( + props: EntityTabsGroupProps, + ref: any, +) { + const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null); + + const open = Boolean(anchorEl); + const submenuId = open ? 'tabbed-submenu' : undefined; + + const { + classes, + className, + disabled = false, + disableFocusRipple = false, + items, + fullWidth, + icon, + indicator, + label, + onSelectTab, + selected, + textColor = 'inherit', + wrapped = false, + highlightedButton, + } = props; + + const testId = 'data-testid' in props && props['data-testid']; + + const handleMenuClose = () => { + setAnchorEl(null); + }; + + const handleMenuClick = (event: MouseEvent<HTMLButtonElement>) => { + setAnchorEl(event.currentTarget); + }; + + const classArray = [ + classes?.root, + classes?.[`textColor${capitalize(textColor)}` as TabClassKey], + classes && { + [classes.disabled!]: disabled, + [classes.selected!]: selected, + [classes.labelIcon!]: label && icon, + [classes.fullWidth!]: fullWidth, + [classes.wrapped!]: wrapped, + }, + className, + ]; + + const innerButtonClasses = [ + classes?.root, + classes?.[`textColor${capitalize(textColor)}` as TabClassKey], + classes?.defaultTab, + classes && { + [classes.disabled!]: disabled, + [classes.labelIcon!]: label && icon, + [classes.fullWidth!]: fullWidth, + [classes.wrapped!]: wrapped, + }, + ]; + + if (items.length === 1) { + return ( + <ButtonBase + focusRipple={!disableFocusRipple} + data-testid={testId} + className={classnames(classArray)} + ref={ref} + role="tab" + aria-selected={selected} + disabled={disabled} + component={Link} + onClick={onSelectTab} + to={items[0]?.path} + > + <Typography className={classes?.wrapper} variant="button"> + {icon} + {items[0].label} + </Typography> + {indicator} + </ButtonBase> + ); + } + return ( + <> + <ButtonBase + data-testid={testId} + focusRipple={!disableFocusRipple} + className={classnames(classArray)} + ref={ref} + role="tab" + aria-selected={selected} + disabled={disabled} + onClick={handleMenuClick} + > + <Typography className={classes?.wrapper} variant="button"> + {label} + </Typography> + <ExpandMoreIcon /> + </ButtonBase> + <Popover + id={submenuId} + open={open} + anchorEl={anchorEl} + onClose={handleMenuClose} + anchorOrigin={{ + vertical: 'bottom', + horizontal: 'center', + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'center', + }} + > + {items.map((i, idx) => ( + <div key={`popover_item_${idx}`}> + <ButtonBase + focusRipple={!disableFocusRipple} + className={classnames( + innerButtonClasses, + classes?.popInButton, + highlightedButton === i.index + ? classes?.selectedButton + : classes?.unselectedButton, + )} + ref={ref} + aria-selected={selected} + disabled={disabled} + component={Link} + onClick={e => { + handleMenuClose(); + onSelectTab(e); + }} + to={i.path} + > + <Typography className={classes?.wrapper} variant="button"> + {icon} + {i.label} + </Typography> + {indicator} + </ButtonBase> + </div> + ))} + </Popover> + </> + ); +}); + +// @ts-ignore +export const EntityTabsGroup = withStyles(styles, { name: 'MuiTab' })(Tab); diff --git a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx new file mode 100644 index 0000000000..7df4bcef0a --- /dev/null +++ b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx @@ -0,0 +1,145 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import Box from '@material-ui/core/Box'; +import Tabs from '@material-ui/core/Tabs'; +import { makeStyles } from '@material-ui/core/styles'; +import { EntityTabsGroup } from './EntityTabsGroup'; + +/** @public */ +export type HeaderTabsClassKey = + | 'tabsWrapper' + | 'defaultTab' + | 'selected' + | 'tabRoot'; + +const useStyles = makeStyles( + theme => ({ + tabsWrapper: { + gridArea: 'pageSubheader', + backgroundColor: theme.palette.background.paper, + paddingLeft: theme.spacing(3), + minWidth: 0, + }, + defaultTab: { + ...theme.typography.caption, + padding: theme.spacing(3, 3), + textTransform: 'uppercase', + fontWeight: theme.typography.fontWeightBold, + color: theme.palette.text.secondary, + }, + selected: { + color: theme.palette.text.primary, + }, + tabRoot: { + '&:hover': { + backgroundColor: theme.palette.background.default, + color: theme.palette.text.primary, + }, + }, + }), + { name: 'BackstageHeaderTabs' }, +); + +type Tab = { + id: string; + label: string; + path: string; + group: string; +}; + +type TabItem = { + group: string; + id: string; + index: number; + label: string; + path: string; +}; + +type EntityTabsListProps = { + tabs: Tab[]; + selectedIndex?: number; + onChange?: (index: number) => void; +}; + +export function EntityTabsList(props: EntityTabsListProps) { + const styles = useStyles(); + + const { tabs: items, onChange, selectedIndex: selectedItem = 0 } = props; + + const groups = useMemo( + () => [...new Set(items.map(item => item.group))], + [items], + ); + + const [selectedGroup, setSelectedGroup] = useState<number>( + selectedItem && items[selectedItem] + ? groups.indexOf(items[selectedItem].group) + : 0, + ); + + const handleChange = useCallback( + (index: number) => { + if (selectedItem !== index) onChange?.(index); + }, + [selectedItem, onChange], + ); + + useEffect(() => { + if (selectedItem === undefined || !items[selectedItem]) return; + setSelectedGroup(groups.indexOf(items[selectedItem].group)); + }, [items, selectedItem, groups, setSelectedGroup]); + + return ( + <Box className={styles.tabsWrapper}> + <Tabs + selectionFollowsFocus + indicatorColor="primary" + textColor="inherit" + variant="scrollable" + scrollButtons="auto" + aria-label="tabs" + value={selectedGroup} + > + {groups.map((group, groupIndex) => { + const groupItems: TabItem[] = []; + items.forEach((item, itemIndex) => { + if (item.group === group) { + groupItems.push({ + ...item, + index: itemIndex, + }); + } + }); + return ( + <EntityTabsGroup + data-testid={`header-tab-${groupIndex}`} + className={styles.defaultTab} + classes={{ selected: styles.selected, root: styles.tabRoot }} + key={group} + label={group} + value={groupIndex} + items={groupItems} + highlightedButton={selectedItem} + onSelectTab={() => handleChange(groupIndex)} + /> + ); + })} + </Tabs> + </Box> + ); +} diff --git a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsPanel.tsx b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsPanel.tsx new file mode 100644 index 0000000000..e8548c842f --- /dev/null +++ b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsPanel.tsx @@ -0,0 +1,71 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import classNames from 'classnames'; +import React, { PropsWithChildren } from 'react'; +import { makeStyles, Theme } from '@material-ui/core/styles'; + +/** @public */ +export type EntityTabsPanelClassKey = 'root' | 'stretch' | 'noPadding'; + +const useStyles = makeStyles( + (theme: Theme) => ({ + root: { + gridArea: 'pageContent', + minWidth: 0, + paddingTop: theme.spacing(3), + paddingBottom: theme.spacing(3), + paddingLeft: theme.spacing(2), + paddingRight: theme.spacing(2), + [theme.breakpoints.up('sm')]: { + paddingLeft: theme.spacing(3), + paddingRight: theme.spacing(3), + }, + }, + stretch: { + display: 'flex', + flexDirection: 'column', + flexGrow: 1, + }, + noPadding: { + padding: 0, + }, + }), + { name: 'EntityTabsPanel' }, +); + +type EntityTabsPanelProps = PropsWithChildren<{ + stretch?: boolean; + noPadding?: boolean; + className?: string; +}>; + +export function EntityTabsPanel(props: EntityTabsPanelProps) { + const { className, stretch, noPadding, children, ...restProps } = props; + + const classes = useStyles(); + return ( + <article + {...restProps} + className={classNames(classes.root, className, { + [classes.stretch]: stretch, + [classes.noPadding]: noPadding, + })} + > + {children} + </article> + ); +} diff --git a/plugins/catalog/src/alpha/components/EntityTabs/index.ts b/plugins/catalog/src/alpha/components/EntityTabs/index.ts new file mode 100644 index 0000000000..06a0c52739 --- /dev/null +++ b/plugins/catalog/src/alpha/components/EntityTabs/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 { EntityTabs } from './EntityTabs'; diff --git a/plugins/catalog/src/alpha/pages.test.tsx b/plugins/catalog/src/alpha/pages.test.tsx new file mode 100644 index 0000000000..c21fd51f62 --- /dev/null +++ b/plugins/catalog/src/alpha/pages.test.tsx @@ -0,0 +1,471 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { + createExtensionTester, + renderInTestApp, + TestApiProvider, +} from '@backstage/frontend-test-utils'; +import { catalogEntityPage } from './pages'; +import { EntityContentBlueprint } from '@backstage/plugin-catalog-react/alpha'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; +import { + catalogApiRef, + entityRouteRef, + MockStarredEntitiesApi, + starredEntitiesApiRef, +} from '@backstage/plugin-catalog-react'; +import { convertLegacyRouteRef } from '@backstage/core-compat-api'; +import { rootRouteRef } from '../routes'; + +describe('Index page', () => { + const entityMock = { + metadata: { + namespace: 'default', + annotations: { + 'backstage.io/managed-by-location': + 'file:/Users/camilal/Workspace/backstage/packages/catalog-model/examples/components/artist-lookup-component.yaml', + 'backstage.io/managed-by-origin-location': + 'file:/Users/camilal/Workspace/backstage/packages/catalog-model/examples/all.yaml', + 'backstage.io/source-template': 'template:default/springboot-template', + 'backstage.io/linguist': + 'https://github.com/backstage/backstage/tree/master/plugins/playlist', + }, + name: 'artist-lookup', + description: 'Artist Lookup', + tags: ['java', 'data'], + links: [ + { + url: 'https://example.com/user', + title: 'Examples Users', + icon: 'user', + }, + { + url: 'https://example.com/group', + title: 'Example Group', + icon: 'group', + }, + { + url: 'https://example.com/cloud', + title: 'Link with Cloud Icon', + icon: 'cloud', + }, + { + url: 'https://example.com/dashboard', + title: 'Dashboard', + icon: 'dashboard', + }, + { url: 'https://example.com/help', title: 'Support', icon: 'help' }, + { url: 'https://example.com/web', title: 'Website', icon: 'web' }, + { + url: 'https://example.com/alert', + title: 'Alerts', + icon: 'alert', + }, + ], + uid: '0dc69d61-4715-4912-bd7d-a0d44b421db0', + etag: 'dcebc518ac79e77356cb34df119a523de51cd522', + }, + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + spec: { + type: 'service', + lifecycle: 'experimental', + owner: 'team-a', + system: 'artist-engagement-portal', + dependsOn: ['resource:artists-db'], + apiConsumedBy: ['component:www-artist'], + }, + relations: [ + { type: 'apiConsumedBy', targetRef: 'component:default/www-artist' }, + { type: 'dependsOn', targetRef: 'resource:default/artists-db' }, + { type: 'ownedBy', targetRef: 'group:default/team-a' }, + { + type: 'partOf', + targetRef: 'system:default/artist-engagement-portal', + }, + ], + }; + + const mockCatalogApi = catalogApiMock.mock({ + getEntityByRef: async () => entityMock, + }); + + const mockStarredEntitiesApi = new MockStarredEntitiesApi(); + + const overviewEntityContent = EntityContentBlueprint.make({ + name: 'overview', + params: { + defaultPath: '/overview', + defaultTitle: 'Overview', + defaultGroup: 'documentation', + loader: async () => <div>Mock Overview content</div>, + }, + }); + + const techdocsEntityContent = EntityContentBlueprint.make({ + name: 'techdocs', + params: { + defaultPath: '/techdocs', + defaultTitle: 'TechDocs', + defaultGroup: 'documentation', + loader: async () => <div>Mock TechDocs content</div>, + }, + }); + + const apidocsEntityContent = EntityContentBlueprint.make({ + name: 'apidocs', + params: { + defaultPath: '/apidocs', + defaultTitle: 'ApiDocs', + defaultGroup: 'documentation', + loader: async () => <div>Mock ApiDocs content</div>, + }, + }); + + it('Should render a group as dropdown', async () => { + const tester = createExtensionTester( + Object.assign({ namespace: 'catalog' }, catalogEntityPage), + ) + .add(techdocsEntityContent) + .add(apidocsEntityContent); + + await renderInTestApp( + <TestApiProvider + apis={[ + [catalogApiRef, mockCatalogApi], + [starredEntitiesApiRef, mockStarredEntitiesApi], + ]} + > + {tester.reactElement()} + </TestApiProvider>, + { + config: { + app: { + title: 'Custom app', + }, + backend: { baseUrl: 'http://localhost:7000' }, + }, + mountedRoutes: { + '/catalog': convertLegacyRouteRef(rootRouteRef), + '/catalog/:namespace/:kind/:name': + convertLegacyRouteRef(entityRouteRef), + }, + }, + ); + + await waitFor(() => + expect( + screen.getByRole('tab', { name: /Documentation/ }), + ).toBeInTheDocument(), + ); + + await userEvent.click(screen.getByRole('tab', { name: /Documentation/ })); + + await waitFor(() => + expect(screen.getByRole('button', { name: /TechDocs/ })).toHaveAttribute( + 'href', + '/techdocs', + ), + ); + + await waitFor(() => + expect(screen.getByRole('button', { name: /ApiDocs/ })).toHaveAttribute( + 'href', + '/apidocs', + ), + ); + }); + + it('Should rename a default group', async () => { + const tester = createExtensionTester( + Object.assign({ namespace: 'catalog' }, catalogEntityPage), + { + config: { + groups: { + documentation: 'Docs', + }, + }, + }, + ) + .add(techdocsEntityContent) + .add(apidocsEntityContent); + + await renderInTestApp( + <TestApiProvider + apis={[ + [catalogApiRef, mockCatalogApi], + [starredEntitiesApiRef, mockStarredEntitiesApi], + ]} + > + {tester.reactElement()} + </TestApiProvider>, + { + config: { + app: { + title: 'Custom app', + }, + backend: { baseUrl: 'http://localhost:7000' }, + }, + mountedRoutes: { + '/catalog': convertLegacyRouteRef(rootRouteRef), + '/catalog/:namespace/:kind/:name': + convertLegacyRouteRef(entityRouteRef), + }, + }, + ); + + await waitFor(() => + expect(screen.queryByRole('tab', { name: /Docs/ })).toBeInTheDocument(), + ); + + await userEvent.click(screen.getByRole('tab', { name: /Docs/ })); + + await waitFor(() => + expect(screen.getByRole('button', { name: /TechDocs/ })).toHaveAttribute( + 'href', + '/techdocs', + ), + ); + + await waitFor(() => + expect(screen.getByRole('button', { name: /ApiDocs/ })).toHaveAttribute( + 'href', + '/apidocs', + ), + ); + }); + + it('Should disable a default group', async () => { + const tester = createExtensionTester( + Object.assign({ namespace: 'catalog' }, catalogEntityPage), + { + config: { + groups: { + documentation: false, + }, + }, + }, + ) + .add(techdocsEntityContent) + .add(apidocsEntityContent); + + await renderInTestApp( + <TestApiProvider + apis={[ + [catalogApiRef, mockCatalogApi], + [starredEntitiesApiRef, mockStarredEntitiesApi], + ]} + > + {tester.reactElement()} + </TestApiProvider>, + { + config: { + app: { + title: 'Custom app', + }, + backend: { baseUrl: 'http://localhost:7000' }, + }, + mountedRoutes: { + '/catalog': convertLegacyRouteRef(rootRouteRef), + '/catalog/:namespace/:kind/:name': + convertLegacyRouteRef(entityRouteRef), + }, + }, + ); + + await waitFor(() => + expect( + screen.queryByRole('tab', { name: /Documentation/ }), + ).not.toBeInTheDocument(), + ); + + await waitFor(() => + expect(screen.getByRole('tab', { name: /TechDocs/ })).toBeInTheDocument(), + ); + + await waitFor(() => + expect(screen.getByRole('tab', { name: /ApiDocs/ })).toBeInTheDocument(), + ); + }); + + it('Should disassociate a content with a default group', async () => { + const tester = createExtensionTester( + Object.assign({ namespace: 'catalog' }, catalogEntityPage), + ) + .add(techdocsEntityContent) + .add(apidocsEntityContent, { + config: { + group: false, + }, + }); + + await renderInTestApp( + <TestApiProvider + apis={[ + [catalogApiRef, mockCatalogApi], + [starredEntitiesApiRef, mockStarredEntitiesApi], + ]} + > + {tester.reactElement()} + </TestApiProvider>, + { + config: { + app: { + title: 'Custom app', + }, + backend: { baseUrl: 'http://localhost:7000' }, + }, + mountedRoutes: { + '/catalog': convertLegacyRouteRef(rootRouteRef), + '/catalog/:namespace/:kind/:name': + convertLegacyRouteRef(entityRouteRef), + }, + }, + ); + + await waitFor(() => + expect( + screen.queryByRole('tab', { name: /Documentation/ }), + ).not.toBeInTheDocument(), + ); + + await waitFor(() => + expect(screen.getByRole('tab', { name: /TechDocs/ })).toBeInTheDocument(), + ); + + await waitFor(() => + expect(screen.getByRole('tab', { name: /ApiDocs/ })).toBeInTheDocument(), + ); + }); + + it('Should create a custom group', async () => { + const tester = createExtensionTester( + Object.assign({ namespace: 'catalog' }, catalogEntityPage), + { + config: { + groups: { + docs: 'Docs', + }, + }, + }, + ) + .add(techdocsEntityContent, { + config: { + group: 'docs', + }, + }) + .add(apidocsEntityContent, { + config: { + group: 'docs', + }, + }); + + await renderInTestApp( + <TestApiProvider + apis={[ + [catalogApiRef, mockCatalogApi], + [starredEntitiesApiRef, mockStarredEntitiesApi], + ]} + > + {tester.reactElement()} + </TestApiProvider>, + { + config: { + app: { + title: 'Custom app', + }, + backend: { baseUrl: 'http://localhost:7000' }, + }, + mountedRoutes: { + '/catalog': convertLegacyRouteRef(rootRouteRef), + '/catalog/:namespace/:kind/:name': + convertLegacyRouteRef(entityRouteRef), + }, + }, + ); + + await waitFor(() => + expect(screen.getByRole('tab', { name: /Docs/ })).toBeInTheDocument(), + ); + + await userEvent.click(screen.getByRole('tab', { name: /Docs/ })); + + await waitFor(() => + expect(screen.getByRole('button', { name: /TechDocs/ })).toHaveAttribute( + 'href', + '/techdocs', + ), + ); + + await waitFor(() => + expect(screen.getByRole('button', { name: /ApiDocs/ })).toHaveAttribute( + 'href', + '/apidocs', + ), + ); + }); + + it('Should render single-content groups as a normal tab', async () => { + const tester = createExtensionTester( + Object.assign({ namespace: 'catalog' }, catalogEntityPage), + ) + .add(techdocsEntityContent) + .add(apidocsEntityContent) + .add(overviewEntityContent, { + config: { + group: 'development', + }, + }); + + await renderInTestApp( + <TestApiProvider + apis={[ + [catalogApiRef, mockCatalogApi], + [starredEntitiesApiRef, mockStarredEntitiesApi], + ]} + > + {tester.reactElement()} + </TestApiProvider>, + { + config: { + app: { + title: 'Custom app', + }, + backend: { baseUrl: 'http://localhost:7000' }, + }, + mountedRoutes: { + '/catalog': convertLegacyRouteRef(rootRouteRef), + '/catalog/:namespace/:kind/:name': + convertLegacyRouteRef(entityRouteRef), + }, + }, + ); + + await waitFor(() => + expect(screen.getByRole('tab', { name: /Overview/ })).toBeInTheDocument(), + ); + + await waitFor(() => + expect( + screen.queryByRole('tab', { name: /Development/ }), + ).not.toBeInTheDocument(), + ); + }); +}); diff --git a/plugins/catalog/src/alpha/pages.tsx b/plugins/catalog/src/alpha/pages.tsx index 764acd1fe5..3fd5ab1d56 100644 --- a/plugins/catalog/src/alpha/pages.tsx +++ b/plugins/catalog/src/alpha/pages.tsx @@ -28,7 +28,10 @@ import { AsyncEntityProvider, entityRouteRef, } from '@backstage/plugin-catalog-react'; -import { EntityContentBlueprint } from '@backstage/plugin-catalog-react/alpha'; +import { + EntityContentBlueprint, + defaultEntityContentGroups, +} from '@backstage/plugin-catalog-react/alpha'; import { rootRouteRef } from '../routes'; import { useEntityFromUrl } from '../components/CatalogEntityPage/useEntityFromUrl'; import { buildFilterFn } from './filter/FilterWrapper'; @@ -62,21 +65,57 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({ EntityContentBlueprint.dataRefs.title, EntityContentBlueprint.dataRefs.filterFunction.optional(), EntityContentBlueprint.dataRefs.filterExpression.optional(), + EntityContentBlueprint.dataRefs.group.optional(), ]), }, - factory(originalFactory, { inputs }) { + config: { + schema: { + groups: z => + z.record(z.string(), z.string().or(z.literal(false))).optional(), + }, + }, + factory(originalFactory, { config, inputs }) { return originalFactory({ defaultPath: '/catalog/:namespace/:kind/:name', routeRef: convertLegacyRouteRef(entityRouteRef), loader: async () => { - const { EntityLayout } = await import('../components/EntityLayout'); + const { EntityLayout } = await import('./components/EntityLayout'); + + // config groups override default groups + const groups: Record<string, string> = { + ...defaultEntityContentGroups, + ...config.groups, + }; + + // the groups order is determined by the order of the contents + // a group will appear in the order of the first item that belongs to it + const tabs = inputs.contents.reduce< + Record<string, Array<(typeof inputs.contents)[0]>> + >((rest, output) => { + const itemTitle = output.get(EntityContentBlueprint.dataRefs.title); + const groupId = output.get(EntityContentBlueprint.dataRefs.group); + const groupTitle = groupId && groups[groupId]; + // disabled or invalid groups are ignored + if (!groupTitle) { + return { + ...rest, + [itemTitle]: [output], + }; + } + return { + ...rest, + [groupTitle]: [...(rest[groupTitle] ?? []), output], + }; + }, {}); + const Component = () => { return ( <AsyncEntityProvider {...useEntityFromUrl()}> <EntityLayout> - {inputs.contents.map(output => { - return ( + {Object.entries(tabs).flatMap(([group, items]) => + items.map(output => ( <EntityLayout.Route + group={group} key={output.get(coreExtensionData.routePath)} path={output.get(coreExtensionData.routePath)} title={output.get(EntityContentBlueprint.dataRefs.title)} @@ -91,12 +130,13 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({ > {output.get(coreExtensionData.reactElement)} </EntityLayout.Route> - ); - })} + )), + )} </EntityLayout> </AsyncEntityProvider> ); }; + return compatWrapper(<Component />); }, }); diff --git a/plugins/kubernetes/report-alpha.api.md b/plugins/kubernetes/report-alpha.api.md index 19f0554291..3544768dfc 100644 --- a/plugins/kubernetes/report-alpha.api.md +++ b/plugins/kubernetes/report-alpha.api.md @@ -70,11 +70,13 @@ const _default: FrontendPlugin< path: string | undefined; title: string | undefined; filter: string | undefined; + group: string | false | undefined; }; configInput: { filter?: string | undefined; title?: string | undefined; path?: string | undefined; + group?: string | false | undefined; }; output: | ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}> @@ -104,12 +106,25 @@ const _default: FrontendPlugin< { optional: true; } + > + | ConfigurableExtensionDataRef< + string | false, + 'catalog.entity-content-group', + { + optional: true; + } >; inputs: {}; params: { loader: () => Promise<JSX.Element>; defaultPath: string; defaultTitle: string; + defaultGroup?: + | 'documentation' + | 'development' + | 'deployment' + | 'observability' + | undefined; routeRef?: RouteRef<AnyRouteRefParams> | undefined; filter?: string | ((entity: Entity) => boolean) | undefined; }; diff --git a/plugins/kubernetes/src/alpha/entityContents.tsx b/plugins/kubernetes/src/alpha/entityContents.tsx index 2703fcdfc2..7b6dc78e4b 100644 --- a/plugins/kubernetes/src/alpha/entityContents.tsx +++ b/plugins/kubernetes/src/alpha/entityContents.tsx @@ -23,6 +23,7 @@ export const entityKubernetesContent = EntityContentBlueprint.make({ params: { defaultPath: '/kubernetes', defaultTitle: 'Kubernetes', + defaultGroup: 'deployment', filter: 'kind:component,resource', loader: () => import('./KubernetesContentPage').then(m => diff --git a/plugins/techdocs/report-alpha.api.md b/plugins/techdocs/report-alpha.api.md index d39c9d31ed..8cc4f07b86 100644 --- a/plugins/techdocs/report-alpha.api.md +++ b/plugins/techdocs/report-alpha.api.md @@ -185,11 +185,13 @@ const _default: FrontendPlugin< path: string | undefined; title: string | undefined; filter: string | undefined; + group: string | false | undefined; }; configInput: { filter?: string | undefined; title?: string | undefined; path?: string | undefined; + group?: string | false | undefined; }; output: | ConfigurableExtensionDataRef< @@ -223,6 +225,13 @@ const _default: FrontendPlugin< { optional: true; } + > + | ConfigurableExtensionDataRef< + string | false, + 'catalog.entity-content-group', + { + optional: true; + } >; inputs: { emptyState: ExtensionInput< @@ -245,6 +254,12 @@ const _default: FrontendPlugin< loader: () => Promise<JSX.Element>; defaultPath: string; defaultTitle: string; + defaultGroup?: + | 'documentation' + | 'development' + | 'deployment' + | 'observability' + | undefined; routeRef?: RouteRef<AnyRouteRefParams> | undefined; filter?: string | ((entity: Entity) => boolean) | undefined; }; diff --git a/yarn.lock b/yarn.lock index 2bab06fc39..7d29335513 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6468,6 +6468,7 @@ __metadata: "@testing-library/user-event": ^14.0.0 "@types/pluralize": ^0.0.33 "@types/react": ^18.0.0 + classnames: ^2.3.1 dataloader: ^2.0.0 expiry-map: ^2.0.0 history: ^5.0.0 @@ -6475,6 +6476,7 @@ __metadata: pluralize: ^8.0.0 react: ^18.0.2 react-dom: ^18.0.2 + react-helmet: 6.1.0 react-router-dom: ^6.3.0 react-use: ^17.2.4 swr: ^2.2.5