From 491a06cbf1a9b2f5d62c89cc248fea01350cb6ee Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Thu, 11 Sep 2025 13:59:56 +0200 Subject: [PATCH 1/8] Add the ability to show icons for the tabs on the entity page (new frontend) Signed-off-by: Andreas Berger --- .changeset/light-meals-cover.md | 10 ++ .../building-plugins/01-index.md | 9 ++ .../03-common-extension-blueprints.md | 25 ++++ packages/app-next/app-config.yaml | 4 + packages/app-next/src/App.tsx | 14 ++ plugins/api-docs/report-alpha.api.md | 22 +++ plugins/catalog-react/report-alpha.api.md | 18 +++ .../EntityContentBlueprint.test.tsx | 12 ++ .../blueprints/EntityContentBlueprint.ts | 10 ++ .../src/alpha/blueprints/extensionData.tsx | 8 ++ .../convertLegacyEntityContentExtension.tsx | 4 +- plugins/catalog/report-alpha.api.md | 25 ++++ .../components/EntityLayout/EntityLayout.tsx | 6 +- .../components/EntityTabs/EntityTabs.tsx | 10 +- .../components/EntityTabs/EntityTabsGroup.tsx | 131 ++++++++++-------- .../components/EntityTabs/EntityTabsList.tsx | 36 +++-- plugins/catalog/src/alpha/pages.tsx | 33 ++++- plugins/kubernetes/report-alpha.api.md | 12 ++ plugins/techdocs/report-alpha.api.md | 12 ++ 19 files changed, 322 insertions(+), 79 deletions(-) create mode 100644 .changeset/light-meals-cover.md diff --git a/.changeset/light-meals-cover.md b/.changeset/light-meals-cover.md new file mode 100644 index 0000000000..80293d1941 --- /dev/null +++ b/.changeset/light-meals-cover.md @@ -0,0 +1,10 @@ +--- +'@backstage/plugin-catalog-react': minor +'@backstage/plugin-kubernetes': minor +'example-app-next': minor +'@backstage/plugin-api-docs': minor +'@backstage/plugin-techdocs': minor +'@backstage/plugin-catalog': minor +--- + +Add the ability to show icons for the tabs on the entity page (new frontend) diff --git a/docs/frontend-system/building-plugins/01-index.md b/docs/frontend-system/building-plugins/01-index.md index d9acf76e15..735e37c89b 100644 --- a/docs/frontend-system/building-plugins/01-index.md +++ b/docs/frontend-system/building-plugins/01-index.md @@ -198,6 +198,15 @@ const exampleEntityContent = EntityContentBlueprint.make({ params: { path: 'example', title: 'Example', + // Optional: associate this content with a group on the entity page tabs + // Use a known default group id like "overview", "quality", "documentation", + // or provide a custom string. You can also override or disable this via app-config (see note below). + // group: 'overview', + // Optional: set a tab icon. When using a string, the icon is resolved via the IconsApi. + // Ensure your app has icon bundles enabled/installed so the icon id is available. + // Note: icons are shown in the entity page tab groups only if `showIcons` is enabled in the + // catalog entity page config (page:catalog/entity) via app-config. + // icon: 'dashboard', loader: () => import('./components/ExampleEntityContent').then(m => ( diff --git a/docs/frontend-system/building-plugins/03-common-extension-blueprints.md b/docs/frontend-system/building-plugins/03-common-extension-blueprints.md index aff66f3cc6..3b8ad29da0 100644 --- a/docs/frontend-system/building-plugins/03-common-extension-blueprints.md +++ b/docs/frontend-system/building-plugins/03-common-extension-blueprints.md @@ -73,6 +73,31 @@ Avoid using `convertLegacyEntityCardExtension` from `@backstage/core-compat-api` Creates entity content to be displayed on the entity pages of the catalog plugin. Exported as `EntityContentBlueprint`. +Supports optional params such as `group` and `icon` in addition to `path`, `title`, `loader`, `filter`, and `routeRef`: + +- group: string | false — associates the content with a tab group on the entity page (for example "overview", "quality", "deployment", or any custom id). You can override or disable this per-installation via app-config using `app.extensions[...].config.group`, where `false` removes the grouping. +- icon: string | ReactElement — sets the tab icon. Note: when providing a string, the icon is looked up via the app's IconsApi; make sure icon bundles are enabled/installed in your app (see the Icons blueprint reference above) so that the icon id you use is available. + +To render icons in the entity page tabs, the page must also have icons enabled via app configuration. Set `showIcons: true` on the catalog entity page config (created via `page:catalog/entity`). Example: + +```yaml +app: + extensions: + # Entity page + - page:catalog/entity: + config: + # Enable tab- and group-icons + showIcons: true + # Optionally override default groups and their icons + groups: + - overview: + title: Overview + icon: dashboard + - documentation: + title: Docs + icon: description +``` + Avoid using `convertLegacyEntityContentExtension` from `@backstage/core-compat-api` to convert legacy entity content extensions to the new system. Instead, use the `EntityContentBlueprint` directly. The legacy converter is only intended to help adapt 3rd party plugins that you don't control, and doesn't produce as good results as using the blueprint directly. ## Extension blueprints in `@backstage/plugin-search-react/alpha` diff --git a/packages/app-next/app-config.yaml b/packages/app-next/app-config.yaml index d21e19f0de..3fdcab269d 100644 --- a/packages/app-next/app-config.yaml +++ b/packages/app-next/app-config.yaml @@ -47,6 +47,7 @@ app: # Pages - page:catalog/entity: config: + showIcons: true groups: # placing a tab at the beginning - overview: @@ -56,6 +57,7 @@ app: # example overriding a default group title - documentation: title: Docs + icon: docs - deployment: title: Deployments # example adding a new group @@ -103,9 +105,11 @@ app: config: # example associating with a default group group: documentation + icon: kind:api - entity-content:techdocs: config: group: documentation + icon: techdocs - entity-content:kubernetes/kubernetes: config: # example disassociating with a default group diff --git a/packages/app-next/src/App.tsx b/packages/app-next/src/App.tsx index 47f717c7ee..bc8017de5b 100644 --- a/packages/app-next/src/App.tsx +++ b/packages/app-next/src/App.tsx @@ -47,6 +47,8 @@ import { pluginInfoResolver } from './pluginInfoResolver'; import { appModuleNav } from './modules/appModuleNav'; import devtoolsPlugin from '@backstage/plugin-devtools/alpha'; import { unprocessedEntitiesDevToolsContent } from '@backstage/plugin-catalog-unprocessed-entities/alpha'; +import { default as catalogPlugin } from '@backstage/plugin-catalog/alpha'; +import InfoIcon from '@material-ui/icons/Info'; /* @@ -113,6 +115,17 @@ const customHomePageModule = createFrontendModule({ ], }); +// customize catalog example +const customizedCatalog = catalogPlugin.withOverrides({ + extensions: [ + catalogPlugin.getExtension('entity-content:catalog/overview').override({ + params: { + icon: , + }, + }), + ], +}); + const notFoundErrorPageModule = createFrontendModule({ pluginId: 'app', extensions: [notFoundErrorPage], @@ -131,6 +144,7 @@ const collectedLegacyPlugins = convertLegacyAppRoot( const app = createApp({ features: [ + customizedCatalog, pagesPlugin, convertedTechdocsPlugin, userSettingsPlugin, diff --git a/plugins/api-docs/report-alpha.api.md b/plugins/api-docs/report-alpha.api.md index 2b42b528aa..33cb3e6f76 100644 --- a/plugins/api-docs/report-alpha.api.md +++ b/plugins/api-docs/report-alpha.api.md @@ -15,8 +15,10 @@ import { ExtensionDataRef } from '@backstage/frontend-plugin-api'; import { ExternalRouteRef } from '@backstage/core-plugin-api'; import { IconComponent } from '@backstage/frontend-plugin-api'; import { JSX as JSX_2 } from 'react'; +import { JSXElementConstructor } from 'react'; import { OverridableExtensionDefinition } from '@backstage/frontend-plugin-api'; import { OverridableFrontendPlugin } from '@backstage/frontend-plugin-api'; +import { ReactElement } from 'react'; import { RouteRef } from '@backstage/core-plugin-api'; import { RouteRef as RouteRef_2 } from '@backstage/frontend-plugin-api'; import { TranslationRef } from '@backstage/frontend-plugin-api'; @@ -335,12 +337,14 @@ const _default: OverridableFrontendPlugin< title: string | undefined; filter: EntityPredicate | undefined; group: string | false | undefined; + icon: string | undefined; }; configInput: { filter?: EntityPredicate | undefined; title?: string | undefined; path?: string | undefined; group?: string | false | undefined; + icon?: string | undefined; }; output: | ExtensionDataRef @@ -373,6 +377,13 @@ const _default: OverridableFrontendPlugin< { optional: true; } + > + | ExtensionDataRef< + string | ReactElement>, + 'catalog.entity-content-icon', + { + optional: true; + } >; inputs: {}; params: { @@ -382,6 +393,7 @@ const _default: OverridableFrontendPlugin< title: string; defaultGroup?: [Error: `Use the 'group' param instead`]; group?: keyof defaultEntityContentGroups | (string & {}); + icon?: string | ReactElement; loader: () => Promise; routeRef?: RouteRef_2; filter?: string | EntityPredicate | ((entity: Entity) => boolean); @@ -395,12 +407,14 @@ const _default: OverridableFrontendPlugin< title: string | undefined; filter: EntityPredicate | undefined; group: string | false | undefined; + icon: string | undefined; }; configInput: { filter?: EntityPredicate | undefined; title?: string | undefined; path?: string | undefined; group?: string | false | undefined; + icon?: string | undefined; }; output: | ExtensionDataRef @@ -433,6 +447,13 @@ const _default: OverridableFrontendPlugin< { optional: true; } + > + | ExtensionDataRef< + string | ReactElement>, + 'catalog.entity-content-icon', + { + optional: true; + } >; inputs: {}; params: { @@ -442,6 +463,7 @@ const _default: OverridableFrontendPlugin< title: string; defaultGroup?: [Error: `Use the 'group' param instead`]; group?: keyof defaultEntityContentGroups | (string & {}); + icon?: string | ReactElement; loader: () => Promise; routeRef?: RouteRef_2; filter?: string | EntityPredicate | ((entity: Entity) => boolean); diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index d4cdf44742..eff709a7a7 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -13,6 +13,8 @@ import { ExtensionDefinition } from '@backstage/frontend-plugin-api'; import { IconLinkVerticalProps } from '@backstage/core-components'; import { JsonValue } from '@backstage/types'; import { JSX as JSX_2 } from 'react'; +import { JSXElementConstructor } from 'react'; +import { ReactElement } from 'react'; import { ReactNode } from 'react'; import { ResourcePermission } from '@backstage/plugin-permission-common'; import { RouteRef } from '@backstage/frontend-plugin-api'; @@ -143,6 +145,7 @@ export function convertLegacyEntityContentExtension( filter?: string | EntityPredicate | ((entity: Entity) => boolean); path?: string; title?: string; + icon?: string | ReactElement; defaultPath?: [Error: `Use the 'path' override instead`]; defaultTitle?: [Error: `Use the 'title' override instead`]; }, @@ -230,6 +233,7 @@ export const EntityContentBlueprint: ExtensionBlueprint<{ title: string; defaultGroup?: [Error: `Use the 'group' param instead`]; group?: keyof typeof defaultEntityContentGroups | (string & {}); + icon?: string | ReactElement; loader: () => Promise; routeRef?: RouteRef; filter?: string | EntityPredicate | ((entity: Entity) => boolean); @@ -265,6 +269,13 @@ export const EntityContentBlueprint: ExtensionBlueprint<{ { optional: true; } + > + | ExtensionDataRef< + string | ReactElement>, + 'catalog.entity-content-icon', + { + optional: true; + } >; inputs: {}; config: { @@ -272,12 +283,14 @@ export const EntityContentBlueprint: ExtensionBlueprint<{ title: string | undefined; filter: EntityPredicate | undefined; group: string | false | undefined; + icon: string | undefined; }; configInput: { filter?: EntityPredicate | undefined; title?: string | undefined; path?: string | undefined; group?: string | false | undefined; + icon?: string | undefined; }; dataRefs: { title: ConfigurableExtensionDataRef< @@ -300,6 +313,11 @@ export const EntityContentBlueprint: ExtensionBlueprint<{ 'catalog.entity-content-group', {} >; + icon: ConfigurableExtensionDataRef< + string | ReactElement>, + 'catalog.entity-content-icon', + {} + >; }; }>; diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.test.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.test.tsx index e80834be92..cd1053db0b 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.test.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.test.tsx @@ -188,6 +188,9 @@ describe('EntityContentBlueprint', () => { }, ], }, + "icon": { + "type": "string", + }, "path": { "type": "string", }, @@ -243,6 +246,15 @@ describe('EntityContentBlueprint', () => { "optional": [Function], "toString": [Function], }, + { + "$$type": "@backstage/ExtensionDataRef", + "config": { + "optional": true, + }, + "id": "catalog.entity-content-icon", + "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 40fbbdf0f8..dde2517095 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts @@ -26,11 +26,13 @@ import { entityFilterExpressionDataRef, entityContentGroupDataRef, defaultEntityContentGroups, + entityContentIconDataRef, } from './extensionData'; import { EntityPredicate } from '../predicates/types'; import { resolveEntityFilterData } from './resolveEntityFilterData'; import { createEntityPredicateSchema } from '../predicates/createEntityPredicateSchema'; import { Entity } from '@backstage/catalog-model'; +import { ReactElement } from 'react'; /** * @alpha @@ -47,12 +49,14 @@ export const EntityContentBlueprint = createExtensionBlueprint({ entityFilterFunctionDataRef.optional(), entityFilterExpressionDataRef.optional(), entityContentGroupDataRef.optional(), + entityContentIconDataRef.optional(), ], dataRefs: { title: entityContentTitleDataRef, filterFunction: entityFilterFunctionDataRef, filterExpression: entityFilterExpressionDataRef, group: entityContentGroupDataRef, + icon: entityContentIconDataRef, }, config: { schema: { @@ -61,6 +65,7 @@ export const EntityContentBlueprint = createExtensionBlueprint({ filter: z => z.union([z.string(), createEntityPredicateSchema(z)]).optional(), group: z => z.literal(false).or(z.string()).optional(), + icon: z => z.string().optional(), }, }, *factory( @@ -80,6 +85,7 @@ export const EntityContentBlueprint = createExtensionBlueprint({ */ defaultGroup?: [Error: `Use the 'group' param instead`]; group?: keyof typeof defaultEntityContentGroups | (string & {}); + icon?: string | ReactElement; loader: () => Promise; routeRef?: RouteRef; filter?: string | EntityPredicate | ((entity: Entity) => boolean); @@ -91,6 +97,7 @@ export const EntityContentBlueprint = createExtensionBlueprint({ // up by packages that depend on `catalog-react`. const path = config.path ?? params.path ?? params.defaultPath; const title = config.title ?? params.title ?? params.defaultTitle; + const icon = config.icon ?? params.icon; const group = config.group ?? params.group ?? params.defaultGroup; yield coreExtensionData.reactElement( @@ -110,5 +117,8 @@ export const EntityContentBlueprint = createExtensionBlueprint({ if (group && typeof group === 'string') { yield entityContentGroupDataRef(group); } + if (icon) { + yield entityContentIconDataRef(icon); + } }, }); diff --git a/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx b/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx index c304ccf7eb..bf85810ff5 100644 --- a/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx @@ -16,12 +16,20 @@ import { Entity } from '@backstage/catalog-model'; import { createExtensionDataRef } from '@backstage/frontend-plugin-api'; +import { ReactElement } from 'react'; /** @internal */ export const entityContentTitleDataRef = createExtensionDataRef().with({ id: 'catalog.entity-content-title', }); +/** @internal */ +export const entityContentIconDataRef = createExtensionDataRef< + string | ReactElement +>().with({ + id: 'catalog.entity-content-icon', +}); + /** @internal */ export const entityFilterFunctionDataRef = createExtensionDataRef< (entity: Entity) => boolean diff --git a/plugins/catalog-react/src/alpha/converters/convertLegacyEntityContentExtension.tsx b/plugins/catalog-react/src/alpha/converters/convertLegacyEntityContentExtension.tsx index 605ee96d71..e929845a2c 100644 --- a/plugins/catalog-react/src/alpha/converters/convertLegacyEntityContentExtension.tsx +++ b/plugins/catalog-react/src/alpha/converters/convertLegacyEntityContentExtension.tsx @@ -26,7 +26,7 @@ import { import { ExtensionDefinition } from '@backstage/frontend-plugin-api'; import kebabCase from 'lodash/kebabCase'; import startCase from 'lodash/startCase'; -import { ComponentType } from 'react'; +import { ComponentType, ReactElement } from 'react'; import { EntityContentBlueprint } from '../blueprints/EntityContentBlueprint'; import { EntityPredicate } from '../predicates/types'; import { Entity } from '@backstage/catalog-model'; @@ -39,6 +39,7 @@ export function convertLegacyEntityContentExtension( filter?: string | EntityPredicate | ((entity: Entity) => boolean); path?: string; title?: string; + icon?: string | ReactElement; /** * @deprecated Use the `path` param instead. @@ -95,6 +96,7 @@ export function convertLegacyEntityContentExtension( title: (overrides?.title ?? overrides?.defaultTitle ?? startCase(infix)) as string, + icon: overrides?.icon, routeRef: mountPoint && convertLegacyRouteRef(mountPoint), loader: async () => compatWrapper(element), }, diff --git a/plugins/catalog/report-alpha.api.md b/plugins/catalog/report-alpha.api.md index 7f68ba4c72..f19dcd5f3c 100644 --- a/plugins/catalog/report-alpha.api.md +++ b/plugins/catalog/report-alpha.api.md @@ -20,8 +20,10 @@ import { ExternalRouteRef } from '@backstage/core-plugin-api'; import { IconComponent } from '@backstage/frontend-plugin-api'; import { IconLinkVerticalProps } from '@backstage/core-components'; import { JSX as JSX_2 } from 'react'; +import { JSXElementConstructor } from 'react'; import { OverridableExtensionDefinition } from '@backstage/frontend-plugin-api'; import { OverridableFrontendPlugin } from '@backstage/frontend-plugin-api'; +import { ReactElement } from 'react'; import { RouteRef } from '@backstage/core-plugin-api'; import { RouteRef as RouteRef_2 } from '@backstage/frontend-plugin-api'; import { SearchResultItemExtensionComponent } from '@backstage/plugin-search-react/alpha'; @@ -733,12 +735,14 @@ const _default: OverridableFrontendPlugin< title: string | undefined; filter: EntityPredicate | undefined; group: string | false | undefined; + icon: string | undefined; }; configInput: { filter?: EntityPredicate | undefined; title?: string | undefined; path?: string | undefined; group?: string | false | undefined; + icon?: string | undefined; }; output: | ExtensionDataRef @@ -771,6 +775,13 @@ const _default: OverridableFrontendPlugin< { optional: true; } + > + | ExtensionDataRef< + string | ReactElement>, + 'catalog.entity-content-icon', + { + optional: true; + } >; inputs: { layouts: ExtensionInput< @@ -838,6 +849,7 @@ const _default: OverridableFrontendPlugin< title: string; defaultGroup?: [Error: `Use the 'group' param instead`]; group?: keyof defaultEntityContentGroups | (string & {}); + icon?: string | ReactElement; loader: () => Promise; routeRef?: RouteRef_2; filter?: string | EntityPredicate | ((entity: Entity) => boolean); @@ -1024,9 +1036,12 @@ const _default: OverridableFrontendPlugin< string, { title: string; + icon?: string | undefined; } >[] | undefined; + showIcons: boolean; + } & { path: string | undefined; }; configInput: { @@ -1035,9 +1050,12 @@ const _default: OverridableFrontendPlugin< string, { title: string; + icon?: string | undefined; } >[] | undefined; + showIcons?: boolean | undefined; + } & { path?: string | undefined; }; output: @@ -1107,6 +1125,13 @@ const _default: OverridableFrontendPlugin< { optional: true; } + > + | ConfigurableExtensionDataRef< + string | ReactElement>, + 'catalog.entity-content-icon', + { + optional: true; + } >, { singleton: false; diff --git a/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx b/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx index 750c8567ee..d02a0b1193 100644 --- a/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx +++ b/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { ComponentProps, ReactNode } from 'react'; +import { ComponentProps, ReactNode, ReactElement } from 'react'; import Alert from '@material-ui/lab/Alert'; @@ -44,7 +44,8 @@ import { EntityTabs } from '../EntityTabs'; export type EntityLayoutRouteProps = { path: string; title: string; - group: string; + group: string | { title: string; icon?: string }; + icon?: string | ReactElement; children: JSX.Element; if?: (entity: Entity) => boolean; }; @@ -132,6 +133,7 @@ export const EntityLayout = (props: EntityLayoutProps) => { title: elementProps.title, group: elementProps.group, children: elementProps.children, + icon: elementProps.icon, }, ]; }), diff --git a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.tsx b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.tsx index 87e38ad8ee..cb4c64300a 100644 --- a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.tsx +++ b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.tsx @@ -13,16 +13,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { useMemo } from 'react'; +import { ReactElement, useMemo } from 'react'; import { Helmet } from 'react-helmet'; import { matchRoutes, useParams, useRoutes, Outlet } from 'react-router-dom'; import { EntityTabsPanel } from './EntityTabsPanel'; import { EntityTabsList } from './EntityTabsList'; type SubRoute = { - group: string; + group: string | { title: string; icon?: string }; path: string; title: string; + icon?: string | ReactElement; children: JSX.Element; }; @@ -87,17 +88,18 @@ export function EntityTabs(props: EntityTabsProps) { const tabs = useMemo( () => routes.map(t => { - const { path, title, group } = t; + const { path, title, group, icon } = t; let to = path; // Remove trailing /* to = to.replace(/\/\*$/, ''); // And remove leading / for relative navigation to = to.replace(/^\//, ''); return { - group, + group: typeof group === 'string' ? { title: group } : group, id: path, path: to, label: title, + icon: icon, }; }), [routes], diff --git a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsGroup.tsx b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsGroup.tsx index 564b83bd25..bc2e4f92b9 100644 --- a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsGroup.tsx +++ b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsGroup.tsx @@ -19,17 +19,24 @@ import { useState, MouseEvent, MouseEventHandler, + ReactElement, } 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'; +import Button from '@material-ui/core/Button'; +import ListItem from '@material-ui/core/ListItem'; +import ListItemIcon from '@material-ui/core/ListItemIcon'; +import ListItemText from '@material-ui/core/ListItemText'; +import List from '@material-ui/core/List'; +import { useApi } from '@backstage/core-plugin-api'; +import { IconsApi, iconsApiRef } from '@backstage/frontend-plugin-api'; const styles = (theme: Theme) => createStyles({ @@ -53,9 +60,6 @@ const styles = (theme: Theme) => minWidth: 160, }, }, - popInButton: { - width: '100%', - }, defaultTab: { ...theme.typography.caption, padding: theme.spacing(3, 3), @@ -141,7 +145,7 @@ type EntityTabsGroupItem = { index: number; label: string; path: string; - group: string; + icon?: string | ReactElement; }; type EntityTabsGroupProps = TabProps & { @@ -152,8 +156,23 @@ type EntityTabsGroupProps = TabProps & { onSelectTab: MouseEventHandler; }; +function resolveIcon( + icon: string | ReactElement | undefined, + iconsApi: IconsApi, +) { + const itemIcon = icon; + if (typeof itemIcon === 'string') { + const Icon = iconsApi.getIcon(itemIcon); + if (Icon) { + return ; + } + } + return itemIcon; +} + const Tab = forwardRef(function Tab(props: EntityTabsGroupProps, ref: any) { const [anchorEl, setAnchorEl] = useState(null); + const iconsApi = useApi(iconsApiRef); const open = Boolean(anchorEl); const submenuId = open ? 'tabbed-submenu' : undefined; @@ -165,7 +184,6 @@ const Tab = forwardRef(function Tab(props: EntityTabsGroupProps, ref: any) { disableFocusRipple = false, items, fullWidth, - icon, indicator, label, onSelectTab, @@ -175,6 +193,7 @@ const Tab = forwardRef(function Tab(props: EntityTabsGroupProps, ref: any) { highlightedButton, } = props; + const groupIcon = resolveIcon(props.icon, iconsApi); const testId = 'data-testid' in props && props['data-testid']; const handleMenuClose = () => { @@ -191,31 +210,24 @@ const Tab = forwardRef(function Tab(props: EntityTabsGroupProps, ref: any) { classes && { [classes.disabled!]: disabled, [classes.selected!]: selected, - [classes.labelIcon!]: label && icon, + [classes.labelIcon!]: label && groupIcon, [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 ( - - {icon} {items[0].label} {indicator} - + ); } + const hasIcons = items.some(i => i.icon); return ( <> - {label} - + - {items.map((i, idx) => ( -
- { - handleMenuClose(); - onSelectTab(e); - }} - to={i.path} - > - - {icon} - {i.label} - - {indicator} - -
- ))} + + {items.map((i, idx) => { + const itemIcon = resolveIcon(i.icon, iconsApi); + return ( + { + handleMenuClose(); + onSelectTab(e); + }} + to={i.path} + > + {itemIcon && {itemIcon}} + + {i.label} + {indicator} + + } + /> + + ); + })} +
); diff --git a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx index c5475ed710..1228d539ed 100644 --- a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx +++ b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { ReactElement, 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'; @@ -59,15 +59,12 @@ type Tab = { id: string; label: string; path: string; - group: string; + group: { title: string; icon?: string }; + icon?: string | ReactElement; }; -type TabItem = { - group: string; - id: string; +type TabItem = Tab & { index: number; - label: string; - path: string; }; type EntityTabsListProps = { @@ -82,13 +79,21 @@ export function EntityTabsList(props: EntityTabsListProps) { const { tabs: items, onChange, selectedIndex: selectedItem = 0 } = props; const groups = useMemo( - () => [...new Set(items.map(item => item.group))], + () => + Object.values( + items.reduce((result, i) => { + result[i.group.title] = i.group; + return result; + }, {} as Record), + ), [items], ); const [selectedGroup, setSelectedGroup] = useState( selectedItem && items[selectedItem] - ? groups.indexOf(items[selectedItem].group) + ? groups.findIndex( + ({ title }) => title === items[selectedItem].group.title, + ) : 0, ); @@ -101,7 +106,11 @@ export function EntityTabsList(props: EntityTabsListProps) { useEffect(() => { if (selectedItem === undefined || !items[selectedItem]) return; - setSelectedGroup(groups.indexOf(items[selectedItem].group)); + setSelectedGroup( + groups.findIndex( + ({ title }) => title === items[selectedItem].group.title, + ), + ); }, [items, selectedItem, groups, setSelectedGroup]); return ( @@ -118,7 +127,7 @@ export function EntityTabsList(props: EntityTabsListProps) { {groups.map((group, groupIndex) => { const groupItems: TabItem[] = []; items.forEach((item, itemIndex) => { - if (item.group === group) { + if (item.group.title === group.title) { groupItems.push({ ...item, index: itemIndex, @@ -130,8 +139,9 @@ export function EntityTabsList(props: EntityTabsListProps) { data-testid={`header-tab-${groupIndex}`} className={styles.defaultTab} classes={{ selected: styles.selected, root: styles.tabRoot }} - key={group} - label={group} + key={group.title} + label={group.title} + icon={group.icon} value={groupIndex} items={groupItems} highlightedButton={selectedItem} diff --git a/plugins/catalog/src/alpha/pages.tsx b/plugins/catalog/src/alpha/pages.tsx index b9b8f9ba1f..c3fd9aa773 100644 --- a/plugins/catalog/src/alpha/pages.tsx +++ b/plugins/catalog/src/alpha/pages.tsx @@ -88,6 +88,7 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({ EntityContentBlueprint.dataRefs.filterFunction.optional(), EntityContentBlueprint.dataRefs.filterExpression.optional(), EntityContentBlueprint.dataRefs.group.optional(), + EntityContentBlueprint.dataRefs.icon.optional(), ]), contextMenuItems: createExtensionInput([ coreExtensionData.reactElement, @@ -98,8 +99,17 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({ schema: { groups: z => z - .array(z.record(z.string(), z.object({ title: z.string() }))) + .array( + z.record( + z.string(), + z.object({ + title: z.string(), + icon: z.string().optional(), + }), + ), + ) .optional(), + showIcons: z => z.boolean().optional().default(false), }, }, factory(originalFactory, { config, inputs }) { @@ -124,7 +134,11 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({ type Groups = Record< string, - { title: string; items: Array<(typeof inputs.contents)[0]> } + { + title: string; + icon?: string; + items: Array<(typeof inputs.contents)[0]>; + } >; // Get available headers, sorted by if they have a filter function or not. @@ -158,7 +172,11 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({ const [groupId, groupValue] = Object.entries(group)[0]; return { ...rest, - [groupId]: { title: groupValue.title, items: [] }, + [groupId]: { + title: groupValue.title, + icon: config.showIcons ? groupValue.icon : undefined, + items: [], + }, }; }, {}); } @@ -192,13 +210,18 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({ header={header} contextMenuItems={filteredMenuItems} > - {Object.values(groups).flatMap(({ title, items }) => + {Object.values(groups).flatMap(({ title, icon, items }) => items.map(output => ( @@ -131,6 +135,13 @@ const _default: OverridableFrontendPlugin< { optional: true; } + > + | ExtensionDataRef< + string | ReactElement>, + 'catalog.entity-content-icon', + { + optional: true; + } >; inputs: {}; params: { @@ -140,6 +151,7 @@ const _default: OverridableFrontendPlugin< title: string; defaultGroup?: [Error: `Use the 'group' param instead`]; group?: keyof defaultEntityContentGroups | (string & {}); + icon?: string | ReactElement; loader: () => Promise; routeRef?: RouteRef_2; filter?: string | EntityPredicate | ((entity: Entity) => boolean); diff --git a/plugins/techdocs/report-alpha.api.md b/plugins/techdocs/report-alpha.api.md index 493d1500da..db712d02b5 100644 --- a/plugins/techdocs/report-alpha.api.md +++ b/plugins/techdocs/report-alpha.api.md @@ -16,8 +16,10 @@ import { ExtensionInput } from '@backstage/frontend-plugin-api'; import { IconComponent } from '@backstage/frontend-plugin-api'; import { IconLinkVerticalProps } from '@backstage/core-components'; import { JSX as JSX_2 } from 'react'; +import { JSXElementConstructor } from 'react'; import { OverridableExtensionDefinition } from '@backstage/frontend-plugin-api'; import { OverridableFrontendPlugin } from '@backstage/frontend-plugin-api'; +import { ReactElement } from 'react'; import { RouteRef } from '@backstage/core-plugin-api'; import { RouteRef as RouteRef_2 } from '@backstage/frontend-plugin-api'; import { SearchResultItemExtensionComponent } from '@backstage/plugin-search-react/alpha'; @@ -126,12 +128,14 @@ const _default: OverridableFrontendPlugin< title: string | undefined; filter: EntityPredicate | undefined; group: string | false | undefined; + icon: string | undefined; }; configInput: { filter?: EntityPredicate | undefined; title?: string | undefined; path?: string | undefined; group?: string | false | undefined; + icon?: string | undefined; }; output: | ExtensionDataRef @@ -164,6 +168,13 @@ const _default: OverridableFrontendPlugin< { optional: true; } + > + | ExtensionDataRef< + string | ReactElement>, + 'catalog.entity-content-icon', + { + optional: true; + } >; inputs: { addons: ExtensionInput< @@ -202,6 +213,7 @@ const _default: OverridableFrontendPlugin< title: string; defaultGroup?: [Error: `Use the 'group' param instead`]; group?: keyof defaultEntityContentGroups | (string & {}); + icon?: string | ReactElement; loader: () => Promise; routeRef?: RouteRef_2; filter?: string | EntityPredicate | ((entity: Entity) => boolean); From f053baa2f7b99aad5c2634604260d57a79a94742 Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Thu, 11 Sep 2025 14:18:29 +0200 Subject: [PATCH 2/8] update the changeset to not include non-updated packages Signed-off-by: Andreas Berger --- .changeset/light-meals-cover.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.changeset/light-meals-cover.md b/.changeset/light-meals-cover.md index 80293d1941..131454de07 100644 --- a/.changeset/light-meals-cover.md +++ b/.changeset/light-meals-cover.md @@ -1,9 +1,5 @@ --- '@backstage/plugin-catalog-react': minor -'@backstage/plugin-kubernetes': minor -'example-app-next': minor -'@backstage/plugin-api-docs': minor -'@backstage/plugin-techdocs': minor '@backstage/plugin-catalog': minor --- From d04264404ec6a4f4bbb3128508c16b7a079fe797 Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Thu, 11 Sep 2025 16:07:10 +0200 Subject: [PATCH 3/8] update doc Signed-off-by: Andreas Berger --- .../software-catalog/catalog-customization.md | 60 +++++++++++++++++++ .../building-plugins/01-index.md | 9 --- .../03-common-extension-blueprints.md | 4 +- 3 files changed, 62 insertions(+), 11 deletions(-) diff --git a/docs/features/software-catalog/catalog-customization.md b/docs/features/software-catalog/catalog-customization.md index a828e6591a..559abdc3f4 100644 --- a/docs/features/software-catalog/catalog-customization.md +++ b/docs/features/software-catalog/catalog-customization.md @@ -696,3 +696,63 @@ filter: targetRef: $in: [group:default/admins, group:default/viewers] ``` + +### Configure groups, titles, and icons + +You can define and customize the tab groups that appear on the entity page, as well as enable icons for both groups and individual tabs. + +```yaml +app: + extensions: + # Entity page (new frontend system) + - page:catalog/entity: + config: + # Show icons next to group and tab titles + showIcons: true + + # Optionally override default groups and their icons + groups: + - overview: + title: Overview + icon: dashboard + - quality: + title: Quality + icon: verified + - documentation: + title: Docs + icon: description +``` + +Notes: + +- Icons for groups and tabs are resolved via the app's IconsApi. When using a string icon id (for example `"dashboard"`), ensure that the corresponding icon bundles are enabled/installed in your app (see the Icons blueprint in the frontend system documentation). +- Group icons are only rendered if `showIcons` is set to `true`. + +### Overriding or disabling a tab's group (per extension) + +Each entity content extension (tabs on the entity page) can declare a default `group` in code. You can override or disable this per installation in `app-config.yaml` using the extension's config: + +```yaml +app: + extensions: + # ... + # Example entity content extension instance id + - entity-content:example/my-content: + config: + # Move this tab to a custom group you defined above + group: custom + # Show an icon for this entity content page but only if + icon: my-icon + + # Disassociate from any group and show as a standalone tab + - entity-content:example/another-content: + config: + group: false +``` + +### Tab icons for entity content + +Entity content extensions can also declare an `icon` parameter. When provided as a string, the icon id is looked up via the IconsApi. For the icon to render: + +- The entity page must have `showIcons: true` (see configuration above). +- The icon id must be available in the app's enabled icon bundles. diff --git a/docs/frontend-system/building-plugins/01-index.md b/docs/frontend-system/building-plugins/01-index.md index 735e37c89b..d9acf76e15 100644 --- a/docs/frontend-system/building-plugins/01-index.md +++ b/docs/frontend-system/building-plugins/01-index.md @@ -198,15 +198,6 @@ const exampleEntityContent = EntityContentBlueprint.make({ params: { path: 'example', title: 'Example', - // Optional: associate this content with a group on the entity page tabs - // Use a known default group id like "overview", "quality", "documentation", - // or provide a custom string. You can also override or disable this via app-config (see note below). - // group: 'overview', - // Optional: set a tab icon. When using a string, the icon is resolved via the IconsApi. - // Ensure your app has icon bundles enabled/installed so the icon id is available. - // Note: icons are shown in the entity page tab groups only if `showIcons` is enabled in the - // catalog entity page config (page:catalog/entity) via app-config. - // icon: 'dashboard', loader: () => import('./components/ExampleEntityContent').then(m => ( diff --git a/docs/frontend-system/building-plugins/03-common-extension-blueprints.md b/docs/frontend-system/building-plugins/03-common-extension-blueprints.md index 3b8ad29da0..bb6f91a5f5 100644 --- a/docs/frontend-system/building-plugins/03-common-extension-blueprints.md +++ b/docs/frontend-system/building-plugins/03-common-extension-blueprints.md @@ -73,10 +73,10 @@ Avoid using `convertLegacyEntityCardExtension` from `@backstage/core-compat-api` Creates entity content to be displayed on the entity pages of the catalog plugin. Exported as `EntityContentBlueprint`. -Supports optional params such as `group` and `icon` in addition to `path`, `title`, `loader`, `filter`, and `routeRef`: +Supports optional params such as `group` and `icon`to: - group: string | false — associates the content with a tab group on the entity page (for example "overview", "quality", "deployment", or any custom id). You can override or disable this per-installation via app-config using `app.extensions[...].config.group`, where `false` removes the grouping. -- icon: string | ReactElement — sets the tab icon. Note: when providing a string, the icon is looked up via the app's IconsApi; make sure icon bundles are enabled/installed in your app (see the Icons blueprint reference above) so that the icon id you use is available. +- icon: string — sets the tab icon. Note: when providing a string, the icon is looked up via the app's IconsApi; make sure icon bundles are enabled/installed in your app (see the Icons blueprint reference above) so that the icon id you use is available. To render icons in the entity page tabs, the page must also have icons enabled via app configuration. Set `showIcons: true` on the catalog entity page config (created via `page:catalog/entity`). Example: From c3a5f972f62be5f17f2d19e250cb91419125552d Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Tue, 16 Sep 2025 12:46:43 +0200 Subject: [PATCH 4/8] Adjustments after review Signed-off-by: Andreas Berger --- .changeset/light-meals-cover.md | 3 + .../software-catalog/catalog-customization.md | 4 +- .../03-common-extension-blueprints.md | 2 +- packages/app-next/src/App.tsx | 2 +- plugins/catalog-react/report-alpha.api.md | 41 +++++-- .../src/alpha/blueprints/extensionData.tsx | 48 +++++++-- .../src/alpha/blueprints/index.ts | 6 +- .../components/EntityLayout/EntityLayout.tsx | 15 ++- .../components/EntityTabs/EntityTabs.tsx | 16 ++- .../components/EntityTabs/EntityTabsGroup.tsx | 27 +++-- .../components/EntityTabs/EntityTabsList.tsx | 102 +++++++----------- plugins/catalog/src/alpha/pages.tsx | 102 +++++------------- 12 files changed, 195 insertions(+), 173 deletions(-) diff --git a/.changeset/light-meals-cover.md b/.changeset/light-meals-cover.md index 131454de07..16aa023c97 100644 --- a/.changeset/light-meals-cover.md +++ b/.changeset/light-meals-cover.md @@ -1,5 +1,8 @@ --- '@backstage/plugin-catalog-react': minor +'@backstage/plugin-kubernetes': minor +'@backstage/plugin-api-docs': minor +'@backstage/plugin-techdocs': minor '@backstage/plugin-catalog': minor --- diff --git a/docs/features/software-catalog/catalog-customization.md b/docs/features/software-catalog/catalog-customization.md index 559abdc3f4..9be7261a86 100644 --- a/docs/features/software-catalog/catalog-customization.md +++ b/docs/features/software-catalog/catalog-customization.md @@ -725,7 +725,7 @@ app: Notes: -- Icons for groups and tabs are resolved via the app's IconsApi. When using a string icon id (for example `"dashboard"`), ensure that the corresponding icon bundles are enabled/installed in your app (see the Icons blueprint in the frontend system documentation). +- Icons for groups and tabs are resolved via the app's IconsApi. When using a string icon id (for example `"dashboard"`), ensure that the corresponding icon bundles are enabled/installed in your app (see the [IconBundleBlueprint documentation](../../reference/frontend-plugin-api.iconbundleblueprint.md)). - Group icons are only rendered if `showIcons` is set to `true`. ### Overriding or disabling a tab's group (per extension) @@ -741,7 +741,7 @@ app: config: # Move this tab to a custom group you defined above group: custom - # Show an icon for this entity content page but only if + # Show an icon for this entity content page but only if `showIcons` is enabled for the `page:catalog/entity` extension icon: my-icon # Disassociate from any group and show as a standalone tab diff --git a/docs/frontend-system/building-plugins/03-common-extension-blueprints.md b/docs/frontend-system/building-plugins/03-common-extension-blueprints.md index bb6f91a5f5..e10b5fcce6 100644 --- a/docs/frontend-system/building-plugins/03-common-extension-blueprints.md +++ b/docs/frontend-system/building-plugins/03-common-extension-blueprints.md @@ -73,7 +73,7 @@ Avoid using `convertLegacyEntityCardExtension` from `@backstage/core-compat-api` Creates entity content to be displayed on the entity pages of the catalog plugin. Exported as `EntityContentBlueprint`. -Supports optional params such as `group` and `icon`to: +Supports optional params such as `group` and `icon` to: - group: string | false — associates the content with a tab group on the entity page (for example "overview", "quality", "deployment", or any custom id). You can override or disable this per-installation via app-config using `app.extensions[...].config.group`, where `false` removes the grouping. - icon: string — sets the tab icon. Note: when providing a string, the icon is looked up via the app's IconsApi; make sure icon bundles are enabled/installed in your app (see the Icons blueprint reference above) so that the icon id you use is available. diff --git a/packages/app-next/src/App.tsx b/packages/app-next/src/App.tsx index bc8017de5b..3503805394 100644 --- a/packages/app-next/src/App.tsx +++ b/packages/app-next/src/App.tsx @@ -47,7 +47,7 @@ import { pluginInfoResolver } from './pluginInfoResolver'; import { appModuleNav } from './modules/appModuleNav'; import devtoolsPlugin from '@backstage/plugin-devtools/alpha'; import { unprocessedEntitiesDevToolsContent } from '@backstage/plugin-catalog-unprocessed-entities/alpha'; -import { default as catalogPlugin } from '@backstage/plugin-catalog/alpha'; +import catalogPlugin from '@backstage/plugin-catalog/alpha'; import InfoIcon from '@material-ui/icons/Info'; /* diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index eff709a7a7..51a7f85952 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -152,15 +152,33 @@ export function convertLegacyEntityContentExtension( ): ExtensionDefinition; // @alpha -export const defaultEntityContentGroups: { - overview: string; - documentation: string; - development: string; - deployment: string; - operation: string; - observability: string; +export const defaultEntityContentGroupDefinitions: { + overview: { + title: string; + }; + documentation: { + title: string; + }; + development: { + title: string; + }; + deployment: { + title: string; + }; + operation: { + title: string; + }; + observability: { + title: string; + }; }; +// @alpha @deprecated +export const defaultEntityContentGroups: Record< + keyof typeof defaultEntityContentGroupDefinitions, + string +>; + // @alpha export const EntityCardBlueprint: ExtensionBlueprint<{ kind: 'entity-card'; @@ -604,6 +622,15 @@ export type EntityTableColumnTitleProps = { | 'domain'; }; +// @alpha (undocumented) +export type GroupDefinitions = Record< + string, + { + title: string; + icon?: string | ReactElement; + } +>; + // @alpha export function isOwnerOf(owner: Entity, entity: Entity): boolean; diff --git a/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx b/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx index bf85810ff5..936784410f 100644 --- a/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx @@ -41,18 +41,50 @@ export const entityFilterExpressionDataRef = id: 'catalog.entity-filter-expression', }); +/** @alpha */ +export type GroupDefinitions = Record< + string, + { + title: string; + icon?: string | ReactElement; + } +>; + /** * @alpha * Default entity content groups. */ -export const defaultEntityContentGroups = { - overview: 'Overview', - documentation: 'Documentation', - development: 'Development', - deployment: 'Deployment', - operation: 'Operation', - observability: 'Observability', -}; +export const defaultEntityContentGroupDefinitions = { + overview: { + title: 'Overview', + }, + documentation: { + title: 'Documentation', + }, + development: { + title: 'Development', + }, + deployment: { + title: 'Deployment', + }, + operation: { + title: 'Operation', + }, + observability: { + title: 'Observability', + }, +} satisfies GroupDefinitions; + +/** + * @alpha + * Default entity content groups. + * @deprecated use defaultEntityContentGroupDefinitions + */ +export const defaultEntityContentGroups = Object.fromEntries( + Object.entries(defaultEntityContentGroupDefinitions).map( + ([key, { title }]) => [key, title], + ), +) as Record; /** @internal */ export const entityContentGroupDataRef = createExtensionDataRef().with({ diff --git a/plugins/catalog-react/src/alpha/blueprints/index.ts b/plugins/catalog-react/src/alpha/blueprints/index.ts index 640ebb881c..d71b492063 100644 --- a/plugins/catalog-react/src/alpha/blueprints/index.ts +++ b/plugins/catalog-react/src/alpha/blueprints/index.ts @@ -21,7 +21,11 @@ export { type EntityContentLayoutProps, } from './EntityContentLayoutBlueprint'; export { EntityHeaderBlueprint } from './EntityHeaderBlueprint'; -export { defaultEntityContentGroups } from './extensionData'; +export { + defaultEntityContentGroups, + defaultEntityContentGroupDefinitions, + type GroupDefinitions, +} from './extensionData'; export type { EntityCardType } from './extensionData'; export { EntityContextMenuItemBlueprint, diff --git a/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx b/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx index d02a0b1193..3f557861ec 100644 --- a/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx +++ b/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx @@ -40,11 +40,12 @@ import { import { catalogTranslationRef } from '../../translation'; import { EntityHeader } from '../EntityHeader'; import { EntityTabs } from '../EntityTabs'; +import { GroupDefinitions } from '@backstage/plugin-catalog-react/alpha'; export type EntityLayoutRouteProps = { path: string; title: string; - group: string | { title: string; icon?: string }; + group?: string; icon?: string | ReactElement; children: JSX.Element; if?: (entity: Entity) => boolean; @@ -78,6 +79,8 @@ export interface EntityLayoutProps { * It adds breadcrumbs in the Entity page to enhance user navigation and context awareness. */ parentEntityRelations?: string[]; + groupDefinitions: GroupDefinitions; + showIcons?: boolean; } /** @@ -106,6 +109,8 @@ export const EntityLayout = (props: EntityLayoutProps) => { header, NotFoundComponent, parentEntityRelations, + groupDefinitions, + showIcons, } = props; const { kind } = useRouteRefParams(entityRouteRef); const { entity, loading, error } = useAsyncEntity(); @@ -155,7 +160,13 @@ export const EntityLayout = (props: EntityLayoutProps) => { {loading && } - {entity && } + {entity && ( + + )} {error && ( diff --git a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.tsx b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.tsx index cb4c64300a..acc97d165e 100644 --- a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.tsx +++ b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.tsx @@ -18,9 +18,10 @@ import { Helmet } from 'react-helmet'; import { matchRoutes, useParams, useRoutes, Outlet } from 'react-router-dom'; import { EntityTabsPanel } from './EntityTabsPanel'; import { EntityTabsList } from './EntityTabsList'; +import { GroupDefinitions } from '@backstage/plugin-catalog-react/alpha'; type SubRoute = { - group: string | { title: string; icon?: string }; + group?: string; path: string; title: string; icon?: string | ReactElement; @@ -78,10 +79,12 @@ export function useSelectedSubRoute(subRoutes: SubRoute[]): { type EntityTabsProps = { routes: SubRoute[]; + groupDefinitions: GroupDefinitions; + showIcons?: boolean; }; export function EntityTabs(props: EntityTabsProps) { - const { routes } = props; + const { routes, groupDefinitions, showIcons } = props; const { index, route, element } = useSelectedSubRoute(routes); @@ -95,7 +98,7 @@ export function EntityTabs(props: EntityTabsProps) { // And remove leading / for relative navigation to = to.replace(/^\//, ''); return { - group: typeof group === 'string' ? { title: group } : group, + group, id: path, path: to, label: title, @@ -107,7 +110,12 @@ export function EntityTabs(props: EntityTabsProps) { return ( <> - + {element} diff --git a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsGroup.tsx b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsGroup.tsx index bc2e4f92b9..bc15b3fd05 100644 --- a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsGroup.tsx +++ b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsGroup.tsx @@ -142,7 +142,6 @@ const styles = (theme: Theme) => type EntityTabsGroupItem = { id: string; - index: number; label: string; path: string; icon?: string | ReactElement; @@ -151,15 +150,20 @@ type EntityTabsGroupItem = { type EntityTabsGroupProps = TabProps & { classes?: Partial>; indicator?: ReactNode; - highlightedButton?: number; + highlightedButton?: string; items: EntityTabsGroupItem[]; - onSelectTab: MouseEventHandler; + onSelectTab?: MouseEventHandler; + showIcons?: boolean; }; function resolveIcon( icon: string | ReactElement | undefined, iconsApi: IconsApi, + showIcons: boolean, ) { + if (!showIcons) { + return undefined; + } const itemIcon = icon; if (typeof itemIcon === 'string') { const Icon = iconsApi.getIcon(itemIcon); @@ -191,9 +195,10 @@ const Tab = forwardRef(function Tab(props: EntityTabsGroupProps, ref: any) { textColor = 'inherit', wrapped = false, highlightedButton, + showIcons = false, } = props; - const groupIcon = resolveIcon(props.icon, iconsApi); + const groupIcon = resolveIcon(props.icon, iconsApi, showIcons); const testId = 'data-testid' in props && props['data-testid']; const handleMenuClose = () => { @@ -235,7 +240,7 @@ const Tab = forwardRef(function Tab(props: EntityTabsGroupProps, ref: any) { component={Link} onClick={onSelectTab} to={items[0]?.path} - startIcon={resolveIcon(items[0].icon, iconsApi) ?? groupIcon} + startIcon={resolveIcon(items[0].icon, iconsApi, showIcons)} > {items[0].label} @@ -244,7 +249,7 @@ const Tab = forwardRef(function Tab(props: EntityTabsGroupProps, ref: any) { ); } - const hasIcons = items.some(i => i.icon); + const hasIcons = showIcons && items.some(i => i.icon); return ( <>