Merge pull request #31117 from Andy2003/feature/icons-for-tabs-on-entity-page

Add the ability to show icons for the tabs on the entity page (new frontend)
This commit is contained in:
Patrik Oldsberg
2026-02-05 22:56:26 +01:00
committed by GitHub
20 changed files with 508 additions and 198 deletions
+9
View File
@@ -0,0 +1,9 @@
---
'@backstage/plugin-catalog-react': minor
'@backstage/plugin-kubernetes': patch
'@backstage/plugin-api-docs': patch
'@backstage/plugin-techdocs': patch
'@backstage/plugin-catalog': minor
---
Add the ability to show icons for the tabs on the entity page (new frontend)
@@ -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
showNavItemIcons: 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 [IconBundleBlueprint documentation](https://backstage.io/api/stable/variables/_backstage_plugin-app-react.IconBundleBlueprint.html)).
- Group icons are only rendered if `showNavItemIcons` 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 `showNavItemIcons` is enabled for the `page:catalog/entity` extension
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 `showNavItemIcons: true` (see configuration above).
- The icon id must be available in the app's enabled icon bundles.
@@ -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` 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.
To render icons in the entity page tabs, the page must also have icons enabled via app configuration. Set `showNavItemIcons: 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
showNavItemIcons: 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`
+4
View File
@@ -47,6 +47,7 @@ app:
# Pages
- page:catalog/entity:
config:
showNavItemIcons: 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
+14
View File
@@ -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 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: <InfoIcon />,
},
}),
],
});
const notFoundErrorPageModule = createFrontendModule({
pluginId: 'app',
extensions: [notFoundErrorPage],
@@ -131,6 +144,7 @@ const collectedLegacyPlugins = convertLegacyAppRoot(
const app = createApp({
features: [
customizedCatalog,
pagesPlugin,
convertedTechdocsPlugin,
userSettingsPlugin,
+22
View File
@@ -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<string, 'core.routing.path', {}>
@@ -373,6 +377,13 @@ const _default: OverridableFrontendPlugin<
{
optional: true;
}
>
| ExtensionDataRef<
string | ReactElement<any, string | JSXElementConstructor<any>>,
'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<JSX.Element>;
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<string, 'core.routing.path', {}>
@@ -433,6 +447,13 @@ const _default: OverridableFrontendPlugin<
{
optional: true;
}
>
| ExtensionDataRef<
string | ReactElement<any, string | JSXElementConstructor<any>>,
'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<JSX.Element>;
routeRef?: RouteRef_2;
filter?: string | EntityPredicate | ((entity: Entity) => boolean);
+52 -7
View File
@@ -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,21 +145,40 @@ 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`];
},
): 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';
@@ -230,6 +251,7 @@ export const EntityContentBlueprint: ExtensionBlueprint<{
title: string;
defaultGroup?: [Error: `Use the 'group' param instead`];
group?: keyof typeof defaultEntityContentGroups | (string & {});
icon?: string | ReactElement;
loader: () => Promise<JSX.Element>;
routeRef?: RouteRef;
filter?: string | EntityPredicate | ((entity: Entity) => boolean);
@@ -265,6 +287,13 @@ export const EntityContentBlueprint: ExtensionBlueprint<{
{
optional: true;
}
>
| ExtensionDataRef<
string | ReactElement<any, string | JSXElementConstructor<any>>,
'catalog.entity-content-icon',
{
optional: true;
}
>;
inputs: {};
config: {
@@ -272,12 +301,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,9 +331,23 @@ export const EntityContentBlueprint: ExtensionBlueprint<{
'catalog.entity-content-group',
{}
>;
icon: ConfigurableExtensionDataRef<
string | ReactElement<any, string | JSXElementConstructor<any>>,
'catalog.entity-content-icon',
{}
>;
};
}>;
// @alpha (undocumented)
export type EntityContentGroupDefinitions = Record<
string,
{
title: string;
icon?: string | ReactElement;
}
>;
// @alpha (undocumented)
export const EntityContentLayoutBlueprint: ExtensionBlueprint<{
kind: 'entity-content-layout';
@@ -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],
@@ -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<JSX.Element>;
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);
}
},
});
@@ -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<string>().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
@@ -33,18 +41,50 @@ export const entityFilterExpressionDataRef =
id: 'catalog.entity-filter-expression',
});
/** @alpha */
export type EntityContentGroupDefinitions = 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 EntityContentGroupDefinitions;
/**
* @alpha
* Default entity content groups.
* @deprecated use defaultEntityContentGroupDefinitions
*/
export const defaultEntityContentGroups = Object.fromEntries(
Object.entries(defaultEntityContentGroupDefinitions).map(
([key, { title }]) => [key, title],
),
) as Record<keyof typeof defaultEntityContentGroupDefinitions, string>;
/** @internal */
export const entityContentGroupDataRef = createExtensionDataRef<string>().with({
@@ -21,7 +21,11 @@ export {
type EntityContentLayoutProps,
} from './EntityContentLayoutBlueprint';
export { EntityHeaderBlueprint } from './EntityHeaderBlueprint';
export { defaultEntityContentGroups } from './extensionData';
export {
defaultEntityContentGroups,
defaultEntityContentGroupDefinitions,
type EntityContentGroupDefinitions,
} from './extensionData';
export type { EntityCardType } from './extensionData';
export {
EntityContextMenuItemBlueprint,
@@ -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),
},
+23
View File
@@ -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<string, 'core.routing.path', {}>
@@ -771,6 +775,13 @@ const _default: OverridableFrontendPlugin<
{
optional: true;
}
>
| ExtensionDataRef<
string | ReactElement<any, string | JSXElementConstructor<any>>,
'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<JSX.Element>;
routeRef?: RouteRef_2;
filter?: string | EntityPredicate | ((entity: Entity) => boolean);
@@ -1024,9 +1036,11 @@ const _default: OverridableFrontendPlugin<
string,
{
title: string;
icon?: string | undefined;
}
>[]
| undefined;
showNavItemIcons: boolean;
path: string | undefined;
};
configInput: {
@@ -1035,9 +1049,11 @@ const _default: OverridableFrontendPlugin<
string,
{
title: string;
icon?: string | undefined;
}
>[]
| undefined;
showNavItemIcons?: boolean | undefined;
path?: string | undefined;
};
output:
@@ -1107,6 +1123,13 @@ const _default: OverridableFrontendPlugin<
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
string | ReactElement<any, string | JSXElementConstructor<any>>,
'catalog.entity-content-icon',
{
optional: true;
}
>,
{
singleton: false;
@@ -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';
@@ -40,11 +40,13 @@ import {
import { catalogTranslationRef } from '../../translation';
import { EntityHeader } from '../EntityHeader';
import { EntityTabs } from '../EntityTabs';
import { EntityContentGroupDefinitions } from '@backstage/plugin-catalog-react/alpha';
export type EntityLayoutRouteProps = {
path: string;
title: string;
group: string;
group?: string;
icon?: string | ReactElement;
children: JSX.Element;
if?: (entity: Entity) => boolean;
};
@@ -77,6 +79,8 @@ export interface EntityLayoutProps {
* It adds breadcrumbs in the Entity page to enhance user navigation and context awareness.
*/
parentEntityRelations?: string[];
groupDefinitions: EntityContentGroupDefinitions;
showNavItemIcons?: boolean;
}
/**
@@ -105,6 +109,8 @@ export const EntityLayout = (props: EntityLayoutProps) => {
header,
NotFoundComponent,
parentEntityRelations,
groupDefinitions,
showNavItemIcons,
} = props;
const { kind } = useRouteRefParams(entityRouteRef);
const { entity, loading, error } = useAsyncEntity();
@@ -132,6 +138,7 @@ export const EntityLayout = (props: EntityLayoutProps) => {
title: elementProps.title,
group: elementProps.group,
children: elementProps.children,
icon: elementProps.icon,
},
];
}),
@@ -153,7 +160,13 @@ export const EntityLayout = (props: EntityLayoutProps) => {
{loading && <Progress />}
{entity && <EntityTabs routes={routes} />}
{entity && (
<EntityTabs
routes={routes}
groupDefinitions={groupDefinitions}
showIcons={showNavItemIcons}
/>
)}
{error && (
<Content>
@@ -13,16 +13,18 @@
* 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';
import { EntityContentGroupDefinitions } from '@backstage/plugin-catalog-react/alpha';
type SubRoute = {
group: string;
group?: string;
path: string;
title: string;
icon?: string | ReactElement;
children: JSX.Element;
};
@@ -77,17 +79,19 @@ export function useSelectedSubRoute(subRoutes: SubRoute[]): {
type EntityTabsProps = {
routes: SubRoute[];
groupDefinitions: EntityContentGroupDefinitions;
showIcons?: boolean;
};
export function EntityTabs(props: EntityTabsProps) {
const { routes } = props;
const { routes, groupDefinitions, showIcons } = props;
const { index, route, element } = useSelectedSubRoute(routes);
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(/\/\*$/, '');
@@ -98,6 +102,7 @@ export function EntityTabs(props: EntityTabsProps) {
id: path,
path: to,
label: title,
icon,
};
}),
[routes],
@@ -105,7 +110,12 @@ export function EntityTabs(props: EntityTabsProps) {
return (
<>
<EntityTabsList tabs={tabs} selectedIndex={index} />
<EntityTabsList
tabs={tabs}
selectedIndex={index}
showIcons={showIcons}
groupDefinitions={groupDefinitions}
/>
<EntityTabsPanel>
<Helmet title={route?.title} />
{element}
@@ -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),
@@ -138,22 +142,41 @@ const styles = (theme: Theme) =>
type EntityTabsGroupItem = {
id: string;
index: number;
label: string;
path: string;
group: string;
icon?: string | ReactElement;
};
type EntityTabsGroupProps = TabProps & {
classes?: Partial<ReturnType<typeof styles>>;
indicator?: ReactNode;
highlightedButton?: number;
highlightedButton?: string;
items: EntityTabsGroupItem[];
onSelectTab: MouseEventHandler<HTMLAnchorElement>;
onSelectTab?: MouseEventHandler<HTMLAnchorElement>;
showIcons?: boolean;
};
function resolveIcon(
icon: string | ReactElement | undefined,
iconsApi: IconsApi,
showIcons: boolean,
) {
if (!showIcons) {
return undefined;
}
if (typeof icon === 'string') {
const Icon = iconsApi.getIcon(icon);
if (Icon) {
return <Icon />;
}
return undefined;
}
return icon;
}
const Tab = forwardRef(function Tab(props: EntityTabsGroupProps, ref: any) {
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
const iconsApi = useApi(iconsApiRef);
const open = Boolean(anchorEl);
const submenuId = open ? 'tabbed-submenu' : undefined;
@@ -165,7 +188,6 @@ const Tab = forwardRef(function Tab(props: EntityTabsGroupProps, ref: any) {
disableFocusRipple = false,
items,
fullWidth,
icon,
indicator,
label,
onSelectTab,
@@ -173,8 +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, showIcons);
const testId = 'data-testid' in props && props['data-testid'];
const handleMenuClose = () => {
@@ -191,31 +215,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 (
<ButtonBase
<Button
focusRipple={!disableFocusRipple}
data-testid={testId}
className={classnames(classArray)}
className={classnames(
classArray,
classes && {
[classes.labelIcon!]: label && (items[0].icon ?? groupIcon),
},
)}
ref={ref}
role="tab"
aria-selected={selected}
@@ -223,18 +240,19 @@ const Tab = forwardRef(function Tab(props: EntityTabsGroupProps, ref: any) {
component={Link}
onClick={onSelectTab}
to={items[0]?.path}
startIcon={resolveIcon(items[0].icon, iconsApi, showIcons)}
>
<Typography className={classes?.wrapper} variant="button">
{icon}
{items[0].label}
</Typography>
{indicator}
</ButtonBase>
</Button>
);
}
const hasIcons = showIcons && items.some(i => i.icon);
return (
<>
<ButtonBase
<Button
data-testid={testId}
focusRipple={!disableFocusRipple}
className={classnames(classArray)}
@@ -243,12 +261,13 @@ const Tab = forwardRef(function Tab(props: EntityTabsGroupProps, ref: any) {
aria-selected={selected}
disabled={disabled}
onClick={handleMenuClick}
startIcon={groupIcon}
>
<Typography className={classes?.wrapper} variant="button">
{label}
</Typography>
<ExpandMoreIcon />
</ButtonBase>
</Button>
<Popover
id={submenuId}
open={open}
@@ -263,35 +282,44 @@ const Tab = forwardRef(function Tab(props: EntityTabsGroupProps, ref: any) {
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>
))}
<List component="nav">
{items.map(i => {
const itemIcon = resolveIcon(i.icon, iconsApi, showIcons);
return (
<ListItem
key={`popover_item_${i.id}`}
button
focusRipple={!disableFocusRipple}
classes={{
selected: classnames(classes?.selectedButton),
default: classnames(classes?.unselectedButton),
disabled: classnames(classes?.disabled),
}}
ref={ref}
aria-selected={selected}
disabled={disabled}
selected={highlightedButton === i.id}
component={Link}
onClick={e => {
handleMenuClose();
onSelectTab?.(e);
}}
to={i.path}
>
{itemIcon && <ListItemIcon>{itemIcon}</ListItemIcon>}
<ListItemText
inset={!itemIcon && hasIcons}
primary={
<>
<Typography variant="button">{i.label}</Typography>
{indicator}
</>
}
/>
</ListItem>
);
})}
</List>
</Popover>
</>
);
@@ -14,11 +14,12 @@
* limitations under the License.
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ReactElement, useMemo } 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';
import { EntityContentGroupDefinitions } from '@backstage/plugin-catalog-react/alpha';
/** @public */
export type HeaderTabsClassKey =
@@ -59,51 +60,46 @@ type Tab = {
id: string;
label: string;
path: string;
group: string;
group?: string;
icon?: string | ReactElement;
};
type TabItem = {
group: string;
id: string;
index: number;
label: string;
path: string;
type TabGroup = {
group?: {
title: string;
icon?: string | ReactElement;
};
items: Array<Omit<Tab, 'group'>>;
};
type EntityTabsListProps = {
tabs: Tab[];
groupDefinitions: EntityContentGroupDefinitions;
showIcons?: boolean;
selectedIndex?: number;
onChange?: (index: number) => void;
};
export function EntityTabsList(props: EntityTabsListProps) {
const styles = useStyles();
const { tabs: items, onChange, selectedIndex: selectedItem = 0 } = props;
const { tabs: items, selectedIndex = 0, showIcons, groupDefinitions } = props;
const groups = useMemo(
() => [...new Set(items.map(item => item.group))],
[items],
() =>
items.reduce((result, tab) => {
const group = tab.group ? groupDefinitions[tab.group] : undefined;
const groupOrId = group && tab.group ? tab.group : tab.id;
result[groupOrId] = result[groupOrId] ?? {
group,
items: [],
};
result[groupOrId].items.push(tab);
return result;
}, {} as Record<string, TabGroup>),
[items, groupDefinitions],
);
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]);
const selectedItem = items[selectedIndex];
return (
<Box className={styles.tabsWrapper}>
<Tabs
@@ -113,32 +109,22 @@ export function EntityTabsList(props: EntityTabsListProps) {
variant="scrollable"
scrollButtons="auto"
aria-label="tabs"
value={selectedGroup}
value={selectedItem?.group ?? selectedItem?.id}
>
{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)}
/>
);
})}
{Object.entries(groups).map(([id, tabGroup]) => (
<EntityTabsGroup
data-testid={`header-tab-${id}`}
className={styles.defaultTab}
classes={{ selected: styles.selected, root: styles.tabRoot }}
key={id}
label={tabGroup.group?.title}
icon={tabGroup.group?.icon}
value={id}
items={tabGroup.items}
highlightedButton={selectedItem?.id}
showIcons={showIcons}
/>
))}
</Tabs>
</Box>
);
+40 -61
View File
@@ -25,10 +25,11 @@ import {
entityRouteRef,
} from '@backstage/plugin-catalog-react';
import {
EntityHeaderBlueprint,
defaultEntityContentGroupDefinitions,
EntityContentBlueprint,
defaultEntityContentGroups,
EntityContextMenuItemBlueprint,
EntityHeaderBlueprint,
EntityContentGroupDefinitions,
} from '@backstage/plugin-catalog-react/alpha';
import { rootRouteRef } from '../routes';
import { useEntityFromUrl } from '../components/CatalogEntityPage/useEntityFromUrl';
@@ -88,6 +89,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 +100,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(),
showNavItemIcons: z => z.boolean().optional().default(false),
},
},
factory(originalFactory, { config, inputs }) {
@@ -122,11 +133,6 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({
(() => true),
}));
type Groups = Record<
string,
{ title: string; items: Array<(typeof inputs.contents)[0]> }
>;
// Get available headers, sorted by if they have a filter function or not.
// TODO(blam): we should really have priority or some specificity here which can be used to sort the headers.
// That can be done with embedding the priority in the dataRef alongside the filter function.
@@ -141,39 +147,11 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({
return 0;
});
let groups = Object.entries(defaultEntityContentGroups).reduce<Groups>(
(rest, group) => {
const [groupId, groupValue] = group;
return {
...rest,
[groupId]: { title: groupValue, items: [] },
};
},
{},
);
// config groups override default groups
if (config.groups) {
groups = config.groups.reduce<Groups>((rest, group) => {
const [groupId, groupValue] = Object.entries(group)[0];
return {
...rest,
[groupId]: { title: groupValue.title, items: [] },
};
}, {});
}
for (const output of inputs.contents) {
const itemId = output.node.spec.id;
const itemTitle = output.get(EntityContentBlueprint.dataRefs.title);
const itemGroup = output.get(EntityContentBlueprint.dataRefs.group);
const group = itemGroup && groups[itemGroup];
if (!group) {
groups[itemId] = { title: itemTitle, items: [output] };
continue;
}
group.items.push(output);
}
const groupDefinitions =
config.groups?.reduce(
(rest, group) => ({ ...rest, ...group }),
{} as EntityContentGroupDefinitions,
) ?? defaultEntityContentGroupDefinitions;
const Component = () => {
const entityFromUrl = useEntityFromUrl();
@@ -191,27 +169,28 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({
<EntityLayout
header={header}
contextMenuItems={filteredMenuItems}
groupDefinitions={groupDefinitions}
showNavItemIcons={config.showNavItemIcons}
>
{Object.values(groups).flatMap(({ title, items }) =>
items.map(output => (
<EntityLayout.Route
group={title}
key={output.get(coreExtensionData.routePath)}
path={output.get(coreExtensionData.routePath)}
title={output.get(EntityContentBlueprint.dataRefs.title)}
if={buildFilterFn(
output.get(
EntityContentBlueprint.dataRefs.filterFunction,
),
output.get(
EntityContentBlueprint.dataRefs.filterExpression,
),
)}
>
{output.get(coreExtensionData.reactElement)}
</EntityLayout.Route>
)),
)}
{inputs.contents.map(output => (
<EntityLayout.Route
group={output.get(EntityContentBlueprint.dataRefs.group)}
key={output.get(coreExtensionData.routePath)}
path={output.get(coreExtensionData.routePath)}
title={output.get(EntityContentBlueprint.dataRefs.title)}
icon={output.get(EntityContentBlueprint.dataRefs.icon)}
if={buildFilterFn(
output.get(
EntityContentBlueprint.dataRefs.filterFunction,
),
output.get(
EntityContentBlueprint.dataRefs.filterExpression,
),
)}
>
{output.get(coreExtensionData.reactElement)}
</EntityLayout.Route>
))}
</EntityLayout>
</AsyncEntityProvider>
);
+12
View File
@@ -12,8 +12,10 @@ import { EntityPredicate } from '@backstage/plugin-catalog-react/alpha';
import { ExtensionBlueprintParams } from '@backstage/frontend-plugin-api';
import { ExtensionDataRef } 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';
@@ -93,12 +95,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<string, 'core.routing.path', {}>
@@ -131,6 +135,13 @@ const _default: OverridableFrontendPlugin<
{
optional: true;
}
>
| ExtensionDataRef<
string | ReactElement<any, string | JSXElementConstructor<any>>,
'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<JSX.Element>;
routeRef?: RouteRef_2;
filter?: string | EntityPredicate | ((entity: Entity) => boolean);
+12
View File
@@ -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<string, 'core.routing.path', {}>
@@ -164,6 +168,13 @@ const _default: OverridableFrontendPlugin<
{
optional: true;
}
>
| ExtensionDataRef<
string | ReactElement<any, string | JSXElementConstructor<any>>,
'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<JSX.Element>;
routeRef?: RouteRef_2;
filter?: string | EntityPredicate | ((entity: Entity) => boolean);