Add the ability to show icons for the tabs on the entity page (new frontend)

Signed-off-by: Andreas Berger <andreas@berger-ecommerce.com>
This commit is contained in:
Andreas Berger
2025-09-11 13:59:56 +02:00
parent 52aec33ff4
commit 491a06cbf1
19 changed files with 322 additions and 79 deletions
+10
View File
@@ -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)
@@ -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 => (
<m.ExampleEntityContent />
@@ -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`
+4
View File
@@ -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
+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 { 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: <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);
+18
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,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<JSX.Element>;
routeRef?: RouteRef;
filter?: string | EntityPredicate | ((entity: Entity) => boolean);
@@ -265,6 +269,13 @@ export const EntityContentBlueprint: ExtensionBlueprint<{
{
optional: true;
}
>
| ExtensionDataRef<
string | ReactElement<any, string | JSXElementConstructor<any>>,
'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<any, string | JSXElementConstructor<any>>,
'catalog.entity-content-icon',
{}
>;
};
}>;
@@ -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
@@ -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),
},
+25
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,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<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';
@@ -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,
},
];
}),
@@ -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],
@@ -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<HTMLAnchorElement>;
};
function resolveIcon(
icon: string | ReactElement | undefined,
iconsApi: IconsApi,
) {
const itemIcon = icon;
if (typeof itemIcon === 'string') {
const Icon = iconsApi.getIcon(itemIcon);
if (Icon) {
return <Icon />;
}
}
return itemIcon;
}
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 +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 (
<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 +235,19 @@ const Tab = forwardRef(function Tab(props: EntityTabsGroupProps, ref: any) {
component={Link}
onClick={onSelectTab}
to={items[0]?.path}
startIcon={resolveIcon(items[0].icon, iconsApi) ?? groupIcon}
>
<Typography className={classes?.wrapper} variant="button">
{icon}
{items[0].label}
</Typography>
{indicator}
</ButtonBase>
</Button>
);
}
const hasIcons = items.some(i => i.icon);
return (
<>
<ButtonBase
<Button
data-testid={testId}
focusRipple={!disableFocusRipple}
className={classnames(classArray)}
@@ -243,12 +256,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 +277,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, idx) => {
const itemIcon = resolveIcon(i.icon, iconsApi);
return (
<ListItem
key={`popover_item_${idx}`}
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.index}
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,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<string, Tab['group']>),
),
[items],
);
const [selectedGroup, setSelectedGroup] = useState<number>(
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}
+28 -5
View File
@@ -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 => (
<EntityLayout.Route
group={title}
group={{ title, icon }}
key={output.get(coreExtensionData.routePath)}
path={output.get(coreExtensionData.routePath)}
title={output.get(EntityContentBlueprint.dataRefs.title)}
icon={
config.showIcons
? output.get(EntityContentBlueprint.dataRefs.icon)
: undefined
}
if={buildFilterFn(
output.get(
EntityContentBlueprint.dataRefs.filterFunction,
+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);