Merge pull request #33019 from backstage/rugvip/nfs-entity-page-group-aliases-and-ordering

catalog: add group aliases and configurable content ordering to entity page
This commit is contained in:
Patrik Oldsberg
2026-02-26 23:34:17 +01:00
committed by GitHub
13 changed files with 326 additions and 7 deletions
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog': minor
---
Added support for group alias IDs and configurable content ordering on the entity page. Groups can now declare `aliases` so that content targeting an aliased group is included in the group. A new `contentOrder` option (default `title`) controls how content items within each group are sorted, with support for both a page-level default and per-group overrides.
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-react': minor
---
Added `aliases` and `contentOrder` fields to `EntityContentGroupDefinition`, allowing groups to declare alias IDs and control the sort order of their content items.
@@ -527,6 +527,7 @@ unassign
unbreak
Unconference
undici
ungrouped
unicode
unmanaged
unmount
@@ -728,6 +728,49 @@ 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`.
### Content ordering within groups
By default, content items within each group are sorted alphabetically by title. You can change this with the `contentOrder` option, which supports two modes:
- **`title`** (default) — sort alphabetically by the content extension's title (case-insensitive).
- **`natural`** — preserve the natural extension discovery/registration order.
A page-level `contentOrder` sets the default for all groups, and individual groups can override it:
```yaml
app:
extensions:
- page:catalog/entity:
config:
# Default content order for all groups
contentOrder: title
groups:
- documentation:
title: Docs
# Override: keep natural order for this group
contentOrder: natural
```
Note that content ordering only applies to content items within groups. Ungrouped tabs (those not matching any group definition) always retain their natural order.
### Group aliases
Groups can declare `aliases` — a list of other group IDs that should be treated as equivalent. Any entity content extension targeting an aliased group ID will be included in the aliasing group. This is useful when renaming or merging groups without having to reconfigure individual extensions:
```yaml
app:
extensions:
- page:catalog/entity:
config:
groups:
- develop:
title: Develop
# Content targeting 'development' will appear in this group
aliases:
- development
```
### 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:
+5
View File
@@ -48,6 +48,8 @@ app:
- page:catalog/entity:
config:
showNavItemIcons: true
# default content order for all groups, can be 'title' or 'natural'
# contentOrder: title
groups:
# placing a tab at the beginning
- overview:
@@ -58,6 +60,9 @@ app:
- documentation:
title: Docs
icon: docs
# example aliasing a group
# aliases:
# - docs
- deployment:
title: Deployments
# example adding a new group
@@ -345,6 +345,8 @@ export type EntityContentGroupDefinitions = Record<
{
title: string;
icon?: string | ReactElement;
aliases?: string[];
contentOrder?: 'title' | 'natural';
}
>;
@@ -47,6 +47,10 @@ export type EntityContentGroupDefinitions = Record<
{
title: string;
icon?: string | ReactElement;
/** Other group IDs that should be treated as aliases for this group. */
aliases?: string[];
/** How to sort the content items within this group. Overrides the page-level default. */
contentOrder?: 'title' | 'natural';
}
>;
+6
View File
@@ -1097,9 +1097,12 @@ const _default: OverridableFrontendPlugin<
{
title: string;
icon?: string | undefined;
contentOrder?: 'title' | 'natural' | undefined;
aliases?: string[] | undefined;
}
>[]
| undefined;
contentOrder: 'title' | 'natural';
showNavItemIcons: boolean;
path: string | undefined;
title: string | undefined;
@@ -1111,10 +1114,13 @@ const _default: OverridableFrontendPlugin<
{
title: string;
icon?: string | undefined;
contentOrder?: 'title' | 'natural' | undefined;
aliases?: string[] | undefined;
}
>[]
| undefined;
showNavItemIcons?: boolean | undefined;
contentOrder?: 'title' | 'natural' | undefined;
title?: string | undefined;
path?: string | undefined;
};
@@ -80,6 +80,7 @@ export interface EntityLayoutProps {
*/
parentEntityRelations?: string[];
groupDefinitions: EntityContentGroupDefinitions;
defaultContentOrder?: 'title' | 'natural';
showNavItemIcons?: boolean;
}
@@ -110,6 +111,7 @@ export const EntityLayout = (props: EntityLayoutProps) => {
NotFoundComponent,
parentEntityRelations,
groupDefinitions,
defaultContentOrder,
showNavItemIcons,
} = props;
const { kind } = useRouteRefParams(entityRouteRef);
@@ -164,6 +166,7 @@ export const EntityLayout = (props: EntityLayoutProps) => {
<EntityTabs
routes={routes}
groupDefinitions={groupDefinitions}
defaultContentOrder={defaultContentOrder}
showIcons={showNavItemIcons}
/>
)}
@@ -72,11 +72,12 @@ export function useSelectedSubRoute(subRoutes: SubRoute[]): {
type EntityTabsProps = {
routes: SubRoute[];
groupDefinitions: EntityContentGroupDefinitions;
defaultContentOrder?: 'title' | 'natural';
showIcons?: boolean;
};
export function EntityTabs(props: EntityTabsProps) {
const { routes, groupDefinitions, showIcons } = props;
const { routes, groupDefinitions, defaultContentOrder, showIcons } = props;
const { index, route, element } = useSelectedSubRoute(routes);
@@ -107,6 +108,7 @@ export function EntityTabs(props: EntityTabsProps) {
selectedIndex={index}
showIcons={showIcons}
groupDefinitions={groupDefinitions}
defaultContentOrder={defaultContentOrder}
/>
<EntityTabsPanel>
<Helmet title={route?.title} />
@@ -78,20 +78,59 @@ type TabGroup = {
type EntityTabsListProps = {
tabs: Tab[];
groupDefinitions: EntityContentGroupDefinitions;
defaultContentOrder?: 'title' | 'natural';
showIcons?: boolean;
selectedIndex?: number;
};
function resolveGroupId(
tabGroup: string | undefined,
groupDefinitions: EntityContentGroupDefinitions,
aliasToGroup: Record<string, string>,
): string | undefined {
if (!tabGroup) {
return undefined;
}
if (groupDefinitions[tabGroup]) {
return tabGroup;
}
return aliasToGroup[tabGroup];
}
export function EntityTabsList(props: EntityTabsListProps) {
const styles = useStyles();
const { t } = useTranslationRef(catalogTranslationRef);
const { tabs: items, selectedIndex = 0, showIcons, groupDefinitions } = props;
const {
tabs: items,
selectedIndex = 0,
showIcons,
groupDefinitions,
defaultContentOrder = 'title',
} = props;
const aliasToGroup = useMemo(
() =>
Object.entries(groupDefinitions).reduce((map, [groupId, def]) => {
for (const alias of def.aliases ?? []) {
map[alias] = groupId;
}
return map;
}, {} as Record<string, string>),
[groupDefinitions],
);
const groups = useMemo(() => {
const byKey = items.reduce((result, tab) => {
const group = tab.group ? groupDefinitions[tab.group] : undefined;
const groupOrId = group && tab.group ? tab.group : tab.id;
const resolvedGroupId = resolveGroupId(
tab.group,
groupDefinitions,
aliasToGroup,
);
const group = resolvedGroupId
? groupDefinitions[resolvedGroupId]
: undefined;
const groupOrId = group && resolvedGroupId ? resolvedGroupId : tab.id;
result[groupOrId] = result[groupOrId] ?? {
group,
items: [],
@@ -101,7 +140,7 @@ export function EntityTabsList(props: EntityTabsListProps) {
}, {} as Record<string, TabGroup>);
const groupOrder = Object.keys(groupDefinitions);
return Object.entries(byKey).sort(([a], [b]) => {
const sorted = Object.entries(byKey).sort(([a], [b]) => {
const ai = groupOrder.indexOf(a);
const bi = groupOrder.indexOf(b);
if (ai !== -1 && bi !== -1) {
@@ -115,9 +154,28 @@ export function EntityTabsList(props: EntityTabsListProps) {
}
return 0;
});
}, [items, groupDefinitions]);
for (const [id, tabGroup] of sorted) {
const groupDef = groupDefinitions[id];
if (groupDef) {
const order = groupDef.contentOrder ?? defaultContentOrder;
if (order === 'title') {
tabGroup.items.sort((a, b) =>
a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }),
);
}
}
}
return sorted;
}, [items, groupDefinitions, aliasToGroup, defaultContentOrder]);
const selectedItem = items[selectedIndex];
const selectedGroup = resolveGroupId(
selectedItem?.group,
groupDefinitions,
aliasToGroup,
);
return (
<Box className={styles.tabsWrapper}>
<Tabs
@@ -127,7 +185,7 @@ export function EntityTabsList(props: EntityTabsListProps) {
variant="scrollable"
scrollButtons="auto"
aria-label={t('entityTabs.tabsAriaLabel')}
value={selectedItem?.group ?? selectedItem?.id}
value={selectedGroup ?? selectedItem?.id}
>
{groups.map(([id, tabGroup]) => (
<EntityTabsGroup
+180
View File
@@ -428,6 +428,186 @@ describe('Entity page', () => {
expect(screen.getAllByRole('tab')[1]).toHaveTextContent('Overview');
});
it('Should resolve group aliases', async () => {
const tester = createExtensionTester(
Object.assign({ namespace: 'catalog' }, catalogEntityPage),
{
config: {
groups: [
{
docs: { title: 'Docs', aliases: ['documentation'] },
},
],
},
},
)
.add(techdocsEntityContent)
.add(apidocsEntityContent);
await renderInTestApp(tester.reactElement(), {
apis: [
[catalogApiRef, mockCatalogApi],
[starredEntitiesApiRef, mockStarredEntitiesApi],
],
config: {
app: {
title: 'Custom app',
},
backend: { baseUrl: 'http://localhost:7000' },
},
mountedRoutes: {
'/catalog': convertLegacyRouteRef(rootRouteRef),
'/catalog/:namespace/:kind/:name':
convertLegacyRouteRef(entityRouteRef),
},
});
await waitFor(() =>
expect(screen.getByRole('tab', { name: /Docs/ })).toBeInTheDocument(),
);
await userEvent.click(screen.getByRole('tab', { name: /Docs/ }));
await waitFor(() =>
expect(
screen.getByRole('button', { name: /TechDocs/ }),
).toHaveAttribute('href', '/techdocs'),
);
await waitFor(() =>
expect(screen.getByRole('button', { name: /ApiDocs/ })).toHaveAttribute(
'href',
'/apidocs',
),
);
});
it('Should sort content by title by default', async () => {
const tester = createExtensionTester(
Object.assign({ namespace: 'catalog' }, catalogEntityPage),
)
.add(techdocsEntityContent)
.add(apidocsEntityContent);
await renderInTestApp(tester.reactElement(), {
apis: [
[catalogApiRef, mockCatalogApi],
[starredEntitiesApiRef, mockStarredEntitiesApi],
],
config: {
app: {
title: 'Custom app',
},
backend: { baseUrl: 'http://localhost:7000' },
},
mountedRoutes: {
'/catalog': convertLegacyRouteRef(rootRouteRef),
'/catalog/:namespace/:kind/:name':
convertLegacyRouteRef(entityRouteRef),
},
});
await userEvent.click(
await screen.findByRole('tab', { name: /Documentation/ }),
);
const buttons = await screen.findAllByRole('button', {
name: /Docs/,
});
expect(buttons[0]).toHaveTextContent('ApiDocs');
expect(buttons[1]).toHaveTextContent('TechDocs');
});
it('Should preserve natural order when configured', async () => {
const tester = createExtensionTester(
Object.assign({ namespace: 'catalog' }, catalogEntityPage),
{
config: {
contentOrder: 'natural',
},
},
)
.add(techdocsEntityContent)
.add(apidocsEntityContent);
await renderInTestApp(tester.reactElement(), {
apis: [
[catalogApiRef, mockCatalogApi],
[starredEntitiesApiRef, mockStarredEntitiesApi],
],
config: {
app: {
title: 'Custom app',
},
backend: { baseUrl: 'http://localhost:7000' },
},
mountedRoutes: {
'/catalog': convertLegacyRouteRef(rootRouteRef),
'/catalog/:namespace/:kind/:name':
convertLegacyRouteRef(entityRouteRef),
},
});
await userEvent.click(
await screen.findByRole('tab', { name: /Documentation/ }),
);
const buttons = await screen.findAllByRole('button', {
name: /Docs/,
});
expect(buttons[0]).toHaveTextContent('TechDocs');
expect(buttons[1]).toHaveTextContent('ApiDocs');
});
it('Should support per-group content order override', async () => {
const tester = createExtensionTester(
Object.assign({ namespace: 'catalog' }, catalogEntityPage),
{
config: {
contentOrder: 'title',
groups: [
{
documentation: {
title: 'Documentation',
contentOrder: 'natural',
},
},
],
},
},
)
.add(techdocsEntityContent)
.add(apidocsEntityContent);
await renderInTestApp(tester.reactElement(), {
apis: [
[catalogApiRef, mockCatalogApi],
[starredEntitiesApiRef, mockStarredEntitiesApi],
],
config: {
app: {
title: 'Custom app',
},
backend: { baseUrl: 'http://localhost:7000' },
},
mountedRoutes: {
'/catalog': convertLegacyRouteRef(rootRouteRef),
'/catalog/:namespace/:kind/:name':
convertLegacyRouteRef(entityRouteRef),
},
});
await userEvent.click(
await screen.findByRole('tab', { name: /Documentation/ }),
);
const buttons = await screen.findAllByRole('button', {
name: /Docs/,
});
expect(buttons[0]).toHaveTextContent('TechDocs');
expect(buttons[1]).toHaveTextContent('ApiDocs');
});
it('Should render groups on the correct order', async () => {
const tester = createExtensionTester(
Object.assign({ namespace: 'catalog' }, catalogEntityPage),
+5
View File
@@ -109,10 +109,14 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({
z.object({
title: z.string(),
icon: z.string().optional(),
aliases: z.array(z.string()).optional(),
contentOrder: z.enum(['title', 'natural']).optional(),
}),
),
)
.optional(),
contentOrder: z =>
z.enum(['title', 'natural']).optional().default('title'),
showNavItemIcons: z => z.boolean().optional().default(false),
},
},
@@ -174,6 +178,7 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({
header={header}
contextMenuItems={filteredMenuItems}
groupDefinitions={groupDefinitions}
defaultContentOrder={config.contentOrder}
showNavItemIcons={config.showNavItemIcons}
>
{inputs.contents.map(output => (