diff --git a/.changeset/entity-page-group-aliases-and-ordering-catalog.md b/.changeset/entity-page-group-aliases-and-ordering-catalog.md
new file mode 100644
index 0000000000..b29bd483bc
--- /dev/null
+++ b/.changeset/entity-page-group-aliases-and-ordering-catalog.md
@@ -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.
diff --git a/.changeset/entity-page-group-aliases-and-ordering.md b/.changeset/entity-page-group-aliases-and-ordering.md
new file mode 100644
index 0000000000..35b18063d9
--- /dev/null
+++ b/.changeset/entity-page-group-aliases-and-ordering.md
@@ -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.
diff --git a/packages/app/app-config.yaml b/packages/app/app-config.yaml
index 0fef43fbb5..d22a7e2fbd 100644
--- a/packages/app/app-config.yaml
+++ b/packages/app/app-config.yaml
@@ -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
diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md
index acd85b5540..a9a054da26 100644
--- a/plugins/catalog-react/report-alpha.api.md
+++ b/plugins/catalog-react/report-alpha.api.md
@@ -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)
diff --git a/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx b/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx
index 83b9aa983b..292c9f367a 100644
--- a/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx
+++ b/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx
@@ -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
>;
/**
diff --git a/plugins/catalog-react/src/alpha/blueprints/index.ts b/plugins/catalog-react/src/alpha/blueprints/index.ts
index 07401018cb..507511358e 100644
--- a/plugins/catalog-react/src/alpha/blueprints/index.ts
+++ b/plugins/catalog-react/src/alpha/blueprints/index.ts
@@ -24,6 +24,7 @@ export { EntityHeaderBlueprint } from './EntityHeaderBlueprint';
export {
defaultEntityContentGroups,
defaultEntityContentGroupDefinitions,
+ type EntityContentGroupDefinition,
type EntityContentGroupDefinitions,
} from './extensionData';
export type { EntityCardType } from './extensionData';
diff --git a/plugins/catalog/report-alpha.api.md b/plugins/catalog/report-alpha.api.md
index c2d856561f..d164c4401f 100644
--- a/plugins/catalog/report-alpha.api.md
+++ b/plugins/catalog/report-alpha.api.md
@@ -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;
};
diff --git a/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx b/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx
index ee6e2fc012..1e86c92ef8 100644
--- a/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx
+++ b/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx
@@ -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) => {
)}
diff --git a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.tsx b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.tsx
index ed737266a5..14a2e755a6 100644
--- a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.tsx
+++ b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.tsx
@@ -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}
/>
diff --git a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx
index 9267e083f5..3f5d735e30 100644
--- a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx
+++ b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx
@@ -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),
+ [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);
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 (
diff --git a/plugins/catalog/src/alpha/pages.test.tsx b/plugins/catalog/src/alpha/pages.test.tsx
index e6bf4a05cf..2c6dfb5c9f 100644
--- a/plugins/catalog/src/alpha/pages.test.tsx
+++ b/plugins/catalog/src/alpha/pages.test.tsx
@@ -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),
diff --git a/plugins/catalog/src/alpha/pages.tsx b/plugins/catalog/src/alpha/pages.tsx
index acb9ec8d51..40aa217442 100644
--- a/plugins/catalog/src/alpha/pages.tsx
+++ b/plugins/catalog/src/alpha/pages.tsx
@@ -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 => (