Adjustments after review

Signed-off-by: Andreas Berger <andreas@berger-ecommerce.com>
This commit is contained in:
Andreas Berger
2025-09-16 12:46:43 +02:00
parent d04264404e
commit c3a5f972f6
12 changed files with 195 additions and 173 deletions
+3
View File
@@ -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
---
@@ -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
@@ -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.
+1 -1
View File
@@ -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';
/*
+34 -7
View File
@@ -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;
@@ -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<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 GroupDefinitions,
} from './extensionData';
export type { EntityCardType } from './extensionData';
export {
EntityContextMenuItemBlueprint,
@@ -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 && <Progress />}
{entity && <EntityTabs routes={routes} />}
{entity && (
<EntityTabs
routes={routes}
groupDefinitions={groupDefinitions}
showIcons={showIcons}
/>
)}
{error && (
<Content>
@@ -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 (
<>
<EntityTabsList tabs={tabs} selectedIndex={index} />
<EntityTabsList
tabs={tabs}
selectedIndex={index}
showIcons={showIcons}
groupDefinitions={groupDefinitions}
/>
<EntityTabsPanel>
<Helmet title={route?.title} />
{element}
@@ -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<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;
}
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)}
>
<Typography className={classes?.wrapper} variant="button">
{items[0].label}
@@ -244,7 +249,7 @@ const Tab = forwardRef(function Tab(props: EntityTabsGroupProps, ref: any) {
</Button>
);
}
const hasIcons = items.some(i => i.icon);
const hasIcons = showIcons && items.some(i => i.icon);
return (
<>
<Button
@@ -278,11 +283,11 @@ const Tab = forwardRef(function Tab(props: EntityTabsGroupProps, ref: any) {
}}
>
<List component="nav">
{items.map((i, idx) => {
const itemIcon = resolveIcon(i.icon, iconsApi);
{items.map(i => {
const itemIcon = resolveIcon(i.icon, iconsApi, showIcons);
return (
<ListItem
key={`popover_item_${idx}`}
key={`popover_item_${i.id}`}
button
focusRipple={!disableFocusRipple}
classes={{
@@ -293,11 +298,11 @@ const Tab = forwardRef(function Tab(props: EntityTabsGroupProps, ref: any) {
ref={ref}
aria-selected={selected}
disabled={disabled}
selected={highlightedButton === i.index}
selected={highlightedButton === i.id}
component={Link}
onClick={e => {
handleMenuClose();
onSelectTab(e);
onSelectTab?.(e);
}}
to={i.path}
>
@@ -14,11 +14,12 @@
* limitations under the License.
*/
import { ReactElement, 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 { GroupDefinitions } from '@backstage/plugin-catalog-react/alpha';
/** @public */
export type HeaderTabsClassKey =
@@ -59,60 +60,46 @@ type Tab = {
id: string;
label: string;
path: string;
group: { title: string; icon?: string };
group?: string;
icon?: string | ReactElement;
};
type TabItem = Tab & {
index: number;
type TabGroup = {
group?: {
title: string;
icon?: string | ReactElement;
};
items: Array<Omit<Tab, 'group'>>;
};
type EntityTabsListProps = {
tabs: Tab[];
groupDefinitions: GroupDefinitions;
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(
() =>
Object.values(
items.reduce((result, i) => {
result[i.group.title] = i.group;
return result;
}, {} as Record<string, Tab['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.findIndex(
({ title }) => title === items[selectedItem].group.title,
)
: 0,
);
const handleChange = useCallback(
(index: number) => {
if (selectedItem !== index) onChange?.(index);
},
[selectedItem, onChange],
);
useEffect(() => {
if (selectedItem === undefined || !items[selectedItem]) return;
setSelectedGroup(
groups.findIndex(
({ title }) => title === items[selectedItem].group.title,
),
);
}, [items, selectedItem, groups, setSelectedGroup]);
const selectedItem = items[selectedIndex];
return (
<Box className={styles.tabsWrapper}>
<Tabs
@@ -122,33 +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.title === group.title) {
groupItems.push({
...item,
index: itemIndex,
});
}
});
return (
<EntityTabsGroup
data-testid={`header-tab-${groupIndex}`}
className={styles.defaultTab}
classes={{ selected: styles.selected, root: styles.tabRoot }}
key={group.title}
label={group.title}
icon={group.icon}
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>
);
+29 -73
View File
@@ -25,10 +25,11 @@ import {
entityRouteRef,
} from '@backstage/plugin-catalog-react';
import {
EntityHeaderBlueprint,
defaultEntityContentGroupDefinitions,
EntityContentBlueprint,
defaultEntityContentGroups,
EntityContextMenuItemBlueprint,
EntityHeaderBlueprint,
GroupDefinitions,
} from '@backstage/plugin-catalog-react/alpha';
import { rootRouteRef } from '../routes';
import { useEntityFromUrl } from '../components/CatalogEntityPage/useEntityFromUrl';
@@ -132,15 +133,6 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({
(() => true),
}));
type Groups = Record<
string,
{
title: string;
icon?: 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.
@@ -155,43 +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,
icon: config.showIcons ? groupValue.icon : undefined,
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 GroupDefinitions,
) ?? defaultEntityContentGroupDefinitions;
const Component = () => {
const entityFromUrl = useEntityFromUrl();
@@ -209,32 +169,28 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({
<EntityLayout
header={header}
contextMenuItems={filteredMenuItems}
groupDefinitions={groupDefinitions}
showIcons={config.showIcons}
>
{Object.values(groups).flatMap(({ title, icon, items }) =>
items.map(output => (
<EntityLayout.Route
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,
),
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>
);