Add group aliases and configurable content ordering to entity page

Add two new configuration features for entity page groups:

- Group alias IDs: groups can declare aliases so that content targeting
  an aliased group ID is included in the aliasing group
- Configurable content ordering: a new contentOrder option (alpha/natural)
  controls how content items within each group are sorted, with support
  for both a page-level default and per-group overrides. The new default
  is alpha (alphabetical by title).

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2026-02-26 00:15:17 +01:00
parent b061a69d14
commit 4d588942d5
12 changed files with 275 additions and 14 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 `alpha`) 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.
+5
View File
@@ -48,6 +48,8 @@ app:
- page:catalog/entity:
config:
showNavItemIcons: true
# default content order for all groups, can be 'alpha' or 'natural'
# contentOrder: alpha
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
+9 -4
View File
@@ -339,13 +339,18 @@ export const EntityContentBlueprint: ExtensionBlueprint<{
};
}>;
// @alpha (undocumented)
export type EntityContentGroupDefinition = {
title: string;
icon?: string | ReactElement;
aliases?: string[];
contentOrder?: 'alpha' | 'natural';
};
// @alpha (undocumented)
export type EntityContentGroupDefinitions = Record<
string,
{
title: string;
icon?: string | ReactElement;
}
EntityContentGroupDefinition
>;
// @alpha (undocumented)
@@ -41,13 +41,20 @@ export const entityFilterExpressionDataRef =
id: 'catalog.entity-filter-expression',
});
/** @alpha */
export type EntityContentGroupDefinition = {
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?: 'alpha' | 'natural';
};
/** @alpha */
export type EntityContentGroupDefinitions = Record<
string,
{
title: string;
icon?: string | ReactElement;
}
EntityContentGroupDefinition
>;
/**
@@ -24,6 +24,7 @@ export { EntityHeaderBlueprint } from './EntityHeaderBlueprint';
export {
defaultEntityContentGroups,
defaultEntityContentGroupDefinitions,
type EntityContentGroupDefinition,
type EntityContentGroupDefinitions,
} from './extensionData';
export type { EntityCardType } from './extensionData';
+6
View File
@@ -1097,9 +1097,12 @@ const _default: OverridableFrontendPlugin<
{
title: string;
icon?: string | undefined;
contentOrder?: 'alpha' | 'natural' | undefined;
aliases?: string[] | undefined;
}
>[]
| undefined;
contentOrder: 'alpha' | 'natural';
showNavItemIcons: boolean;
path: string | undefined;
title: string | undefined;
@@ -1111,10 +1114,13 @@ const _default: OverridableFrontendPlugin<
{
title: string;
icon?: string | undefined;
contentOrder?: 'alpha' | 'natural' | undefined;
aliases?: string[] | undefined;
}
>[]
| undefined;
showNavItemIcons?: boolean | undefined;
contentOrder?: 'alpha' | 'natural' | undefined;
title?: string | undefined;
path?: string | undefined;
};
@@ -80,6 +80,7 @@ export interface EntityLayoutProps {
*/
parentEntityRelations?: string[];
groupDefinitions: EntityContentGroupDefinitions;
defaultContentOrder?: 'alpha' | '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?: 'alpha' | '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,6 +78,7 @@ type TabGroup = {
type EntityTabsListProps = {
tabs: Tab[];
groupDefinitions: EntityContentGroupDefinitions;
defaultContentOrder?: 'alpha' | 'natural';
showIcons?: boolean;
selectedIndex?: number;
};
@@ -86,12 +87,36 @@ 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 = 'alpha',
} = 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 = tab.group
? groupDefinitions[tab.group]
? tab.group
: aliasToGroup[tab.group]
: undefined;
const group = resolvedGroupId
? groupDefinitions[resolvedGroupId]
: undefined;
const groupOrId = group && resolvedGroupId ? resolvedGroupId : tab.id;
result[groupOrId] = result[groupOrId] ?? {
group,
items: [],
@@ -101,7 +126,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,7 +140,19 @@ export function EntityTabsList(props: EntityTabsListProps) {
}
return 0;
});
}, [items, groupDefinitions]);
for (const [id, tabGroup] of sorted) {
const groupDef = groupDefinitions[id];
const order = groupDef?.contentOrder ?? defaultContentOrder;
if (order === 'alpha') {
tabGroup.items.sort((a, b) =>
a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }),
);
}
}
return sorted;
}, [items, groupDefinitions, aliasToGroup, defaultContentOrder]);
const selectedItem = items[selectedIndex];
return (
+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 alphabetically 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: 'alpha',
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(['alpha', 'natural']).optional(),
}),
),
)
.optional(),
contentOrder: z =>
z.enum(['alpha', 'natural']).optional().default('alpha'),
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 => (