From 4d588942d5c78be5707ff513182f568df66f984b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 26 Feb 2026 00:15:17 +0100 Subject: [PATCH 1/5] 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 --- ...page-group-aliases-and-ordering-catalog.md | 5 + .../entity-page-group-aliases-and-ordering.md | 5 + packages/app/app-config.yaml | 5 + plugins/catalog-react/report-alpha.api.md | 13 +- .../src/alpha/blueprints/extensionData.tsx | 15 +- .../src/alpha/blueprints/index.ts | 1 + plugins/catalog/report-alpha.api.md | 6 + .../components/EntityLayout/EntityLayout.tsx | 3 + .../components/EntityTabs/EntityTabs.tsx | 4 +- .../components/EntityTabs/EntityTabsList.tsx | 47 ++++- plugins/catalog/src/alpha/pages.test.tsx | 180 ++++++++++++++++++ plugins/catalog/src/alpha/pages.tsx | 5 + 12 files changed, 275 insertions(+), 14 deletions(-) create mode 100644 .changeset/entity-page-group-aliases-and-ordering-catalog.md create mode 100644 .changeset/entity-page-group-aliases-and-ordering.md 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 => ( From deb4476146018f963c879dfcdaa09de2a76555a0 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 26 Feb 2026 00:32:23 +0100 Subject: [PATCH 2/5] Address review feedback - Rename 'alpha' to 'title' for content order enum value - Inline EntityContentGroupDefinition type into EntityContentGroupDefinitions - Rename defaultContentOrder prop to contentOrder - Apply content ordering to ungrouped tabs as well - Fix no-nested-ternary lint error Signed-off-by: Patrik Oldsberg --- ...page-group-aliases-and-ordering-catalog.md | 2 +- packages/app/app-config.yaml | 4 +-- plugins/catalog-react/report-alpha.api.md | 15 ++++---- .../src/alpha/blueprints/extensionData.tsx | 19 +++++------ .../src/alpha/blueprints/index.ts | 1 - plugins/catalog/report-alpha.api.md | 8 ++--- .../components/EntityLayout/EntityLayout.tsx | 6 ++-- .../components/EntityTabs/EntityTabs.tsx | 6 ++-- .../components/EntityTabs/EntityTabsList.tsx | 34 +++++++++++++------ plugins/catalog/src/alpha/pages.test.tsx | 4 +-- plugins/catalog/src/alpha/pages.tsx | 6 ++-- 11 files changed, 56 insertions(+), 49 deletions(-) diff --git a/.changeset/entity-page-group-aliases-and-ordering-catalog.md b/.changeset/entity-page-group-aliases-and-ordering-catalog.md index b29bd483bc..c2a17ae773 100644 --- a/.changeset/entity-page-group-aliases-and-ordering-catalog.md +++ b/.changeset/entity-page-group-aliases-and-ordering-catalog.md @@ -2,4 +2,4 @@ '@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. +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. diff --git a/packages/app/app-config.yaml b/packages/app/app-config.yaml index d22a7e2fbd..0e49365c08 100644 --- a/packages/app/app-config.yaml +++ b/packages/app/app-config.yaml @@ -48,8 +48,8 @@ app: - page:catalog/entity: config: showNavItemIcons: true - # default content order for all groups, can be 'alpha' or 'natural' - # contentOrder: alpha + # default content order for all groups, can be 'title' or 'natural' + # contentOrder: title groups: # placing a tab at the beginning - overview: diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index a9a054da26..9c76a221dc 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -339,18 +339,15 @@ 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, - EntityContentGroupDefinition + { + title: string; + icon?: string | ReactElement; + aliases?: string[]; + contentOrder?: 'title' | 'natural'; + } >; // @alpha (undocumented) diff --git a/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx b/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx index 292c9f367a..04c13db484 100644 --- a/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx @@ -41,20 +41,17 @@ 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, - 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?: 'title' | 'natural'; + } >; /** diff --git a/plugins/catalog-react/src/alpha/blueprints/index.ts b/plugins/catalog-react/src/alpha/blueprints/index.ts index 507511358e..07401018cb 100644 --- a/plugins/catalog-react/src/alpha/blueprints/index.ts +++ b/plugins/catalog-react/src/alpha/blueprints/index.ts @@ -24,7 +24,6 @@ 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 d164c4401f..2c44d0cefc 100644 --- a/plugins/catalog/report-alpha.api.md +++ b/plugins/catalog/report-alpha.api.md @@ -1097,12 +1097,12 @@ const _default: OverridableFrontendPlugin< { title: string; icon?: string | undefined; - contentOrder?: 'alpha' | 'natural' | undefined; + contentOrder?: 'title' | 'natural' | undefined; aliases?: string[] | undefined; } >[] | undefined; - contentOrder: 'alpha' | 'natural'; + contentOrder: 'title' | 'natural'; showNavItemIcons: boolean; path: string | undefined; title: string | undefined; @@ -1114,13 +1114,13 @@ const _default: OverridableFrontendPlugin< { title: string; icon?: string | undefined; - contentOrder?: 'alpha' | 'natural' | undefined; + contentOrder?: 'title' | 'natural' | undefined; aliases?: string[] | undefined; } >[] | undefined; + contentOrder?: 'title' | 'natural' | 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 1e86c92ef8..ecee929cde 100644 --- a/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx +++ b/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx @@ -80,7 +80,7 @@ export interface EntityLayoutProps { */ parentEntityRelations?: string[]; groupDefinitions: EntityContentGroupDefinitions; - defaultContentOrder?: 'alpha' | 'natural'; + contentOrder?: 'title' | 'natural'; showNavItemIcons?: boolean; } @@ -111,7 +111,7 @@ export const EntityLayout = (props: EntityLayoutProps) => { NotFoundComponent, parentEntityRelations, groupDefinitions, - defaultContentOrder, + contentOrder, showNavItemIcons, } = props; const { kind } = useRouteRefParams(entityRouteRef); @@ -166,7 +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 14a2e755a6..22ae70d726 100644 --- a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.tsx +++ b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.tsx @@ -72,12 +72,12 @@ export function useSelectedSubRoute(subRoutes: SubRoute[]): { type EntityTabsProps = { routes: SubRoute[]; groupDefinitions: EntityContentGroupDefinitions; - defaultContentOrder?: 'alpha' | 'natural'; + contentOrder?: 'title' | 'natural'; showIcons?: boolean; }; export function EntityTabs(props: EntityTabsProps) { - const { routes, groupDefinitions, defaultContentOrder, showIcons } = props; + const { routes, groupDefinitions, contentOrder, showIcons } = props; const { index, route, element } = useSelectedSubRoute(routes); @@ -108,7 +108,7 @@ export function EntityTabs(props: EntityTabsProps) { selectedIndex={index} showIcons={showIcons} groupDefinitions={groupDefinitions} - defaultContentOrder={defaultContentOrder} + contentOrder={contentOrder} /> diff --git a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx index 3f5d735e30..e55129c573 100644 --- a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx +++ b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx @@ -78,11 +78,25 @@ type TabGroup = { type EntityTabsListProps = { tabs: Tab[]; groupDefinitions: EntityContentGroupDefinitions; - defaultContentOrder?: 'alpha' | 'natural'; + contentOrder?: 'title' | 'natural'; showIcons?: boolean; selectedIndex?: number; }; +function resolveGroupId( + tabGroup: string | undefined, + groupDefinitions: EntityContentGroupDefinitions, + aliasToGroup: Record, +): 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); @@ -92,7 +106,7 @@ export function EntityTabsList(props: EntityTabsListProps) { selectedIndex = 0, showIcons, groupDefinitions, - defaultContentOrder = 'alpha', + contentOrder = 'title', } = props; const aliasToGroup = useMemo( @@ -108,11 +122,11 @@ export function EntityTabsList(props: EntityTabsListProps) { const groups = useMemo(() => { const byKey = items.reduce((result, tab) => { - const resolvedGroupId = tab.group - ? groupDefinitions[tab.group] - ? tab.group - : aliasToGroup[tab.group] - : undefined; + const resolvedGroupId = resolveGroupId( + tab.group, + groupDefinitions, + aliasToGroup, + ); const group = resolvedGroupId ? groupDefinitions[resolvedGroupId] : undefined; @@ -143,8 +157,8 @@ export function EntityTabsList(props: EntityTabsListProps) { for (const [id, tabGroup] of sorted) { const groupDef = groupDefinitions[id]; - const order = groupDef?.contentOrder ?? defaultContentOrder; - if (order === 'alpha') { + const order = groupDef?.contentOrder ?? contentOrder; + if (order === 'title') { tabGroup.items.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }), ); @@ -152,7 +166,7 @@ export function EntityTabsList(props: EntityTabsListProps) { } return sorted; - }, [items, groupDefinitions, aliasToGroup, defaultContentOrder]); + }, [items, groupDefinitions, aliasToGroup, contentOrder]); const selectedItem = items[selectedIndex]; return ( diff --git a/plugins/catalog/src/alpha/pages.test.tsx b/plugins/catalog/src/alpha/pages.test.tsx index 2c6dfb5c9f..39f96f8d5e 100644 --- a/plugins/catalog/src/alpha/pages.test.tsx +++ b/plugins/catalog/src/alpha/pages.test.tsx @@ -482,7 +482,7 @@ describe('Entity page', () => { ); }); - it('Should sort content alphabetically by default', async () => { + it('Should sort content by title by default', async () => { const tester = createExtensionTester( Object.assign({ namespace: 'catalog' }, catalogEntityPage), ) @@ -564,7 +564,7 @@ describe('Entity page', () => { Object.assign({ namespace: 'catalog' }, catalogEntityPage), { config: { - contentOrder: 'alpha', + contentOrder: 'title', groups: [ { documentation: { diff --git a/plugins/catalog/src/alpha/pages.tsx b/plugins/catalog/src/alpha/pages.tsx index 40aa217442..61801478f7 100644 --- a/plugins/catalog/src/alpha/pages.tsx +++ b/plugins/catalog/src/alpha/pages.tsx @@ -110,13 +110,13 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({ title: z.string(), icon: z.string().optional(), aliases: z.array(z.string()).optional(), - contentOrder: z.enum(['alpha', 'natural']).optional(), + contentOrder: z.enum(['title', 'natural']).optional(), }), ), ) .optional(), contentOrder: z => - z.enum(['alpha', 'natural']).optional().default('alpha'), + z.enum(['title', 'natural']).optional().default('title'), showNavItemIcons: z => z.boolean().optional().default(false), }, }, @@ -178,7 +178,7 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({ header={header} contextMenuItems={filteredMenuItems} groupDefinitions={groupDefinitions} - defaultContentOrder={config.contentOrder} + contentOrder={config.contentOrder} showNavItemIcons={config.showNavItemIcons} > {inputs.contents.map(output => ( From 3eac7e204cb7bf092aac011e50547c03c43348ec Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 26 Feb 2026 10:04:27 +0100 Subject: [PATCH 3/5] Revert contentOrder to defaultContentOrder, only sort within groups Keep ungrouped tabs in their natural order and only apply content ordering within actual groups. Rename the contentOrder prop back to defaultContentOrder to clarify its scope. Signed-off-by: Patrik Oldsberg --- plugins/catalog/report-alpha.api.md | 2 +- .../components/EntityLayout/EntityLayout.tsx | 6 +++--- .../alpha/components/EntityTabs/EntityTabs.tsx | 6 +++--- .../components/EntityTabs/EntityTabsList.tsx | 18 ++++++++++-------- plugins/catalog/src/alpha/pages.tsx | 2 +- 5 files changed, 18 insertions(+), 16 deletions(-) diff --git a/plugins/catalog/report-alpha.api.md b/plugins/catalog/report-alpha.api.md index 2c44d0cefc..3aae7d8920 100644 --- a/plugins/catalog/report-alpha.api.md +++ b/plugins/catalog/report-alpha.api.md @@ -1119,8 +1119,8 @@ const _default: OverridableFrontendPlugin< } >[] | undefined; - contentOrder?: 'title' | 'natural' | undefined; showNavItemIcons?: boolean | undefined; + contentOrder?: 'title' | '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 ecee929cde..895f36150f 100644 --- a/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx +++ b/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx @@ -80,7 +80,7 @@ export interface EntityLayoutProps { */ parentEntityRelations?: string[]; groupDefinitions: EntityContentGroupDefinitions; - contentOrder?: 'title' | 'natural'; + defaultContentOrder?: 'title' | 'natural'; showNavItemIcons?: boolean; } @@ -111,7 +111,7 @@ export const EntityLayout = (props: EntityLayoutProps) => { NotFoundComponent, parentEntityRelations, groupDefinitions, - contentOrder, + defaultContentOrder, showNavItemIcons, } = props; const { kind } = useRouteRefParams(entityRouteRef); @@ -166,7 +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 22ae70d726..102df76042 100644 --- a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.tsx +++ b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.tsx @@ -72,12 +72,12 @@ export function useSelectedSubRoute(subRoutes: SubRoute[]): { type EntityTabsProps = { routes: SubRoute[]; groupDefinitions: EntityContentGroupDefinitions; - contentOrder?: 'title' | 'natural'; + defaultContentOrder?: 'title' | 'natural'; showIcons?: boolean; }; export function EntityTabs(props: EntityTabsProps) { - const { routes, groupDefinitions, contentOrder, showIcons } = props; + const { routes, groupDefinitions, defaultContentOrder, showIcons } = props; const { index, route, element } = useSelectedSubRoute(routes); @@ -108,7 +108,7 @@ export function EntityTabs(props: EntityTabsProps) { selectedIndex={index} showIcons={showIcons} groupDefinitions={groupDefinitions} - contentOrder={contentOrder} + defaultContentOrder={defaultContentOrder} /> diff --git a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx index e55129c573..d647b52afb 100644 --- a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx +++ b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx @@ -78,7 +78,7 @@ type TabGroup = { type EntityTabsListProps = { tabs: Tab[]; groupDefinitions: EntityContentGroupDefinitions; - contentOrder?: 'title' | 'natural'; + defaultContentOrder?: 'title' | 'natural'; showIcons?: boolean; selectedIndex?: number; }; @@ -106,7 +106,7 @@ export function EntityTabsList(props: EntityTabsListProps) { selectedIndex = 0, showIcons, groupDefinitions, - contentOrder = 'title', + defaultContentOrder = 'title', } = props; const aliasToGroup = useMemo( @@ -157,16 +157,18 @@ export function EntityTabsList(props: EntityTabsListProps) { for (const [id, tabGroup] of sorted) { const groupDef = groupDefinitions[id]; - const order = groupDef?.contentOrder ?? contentOrder; - if (order === 'title') { - tabGroup.items.sort((a, b) => - a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }), - ); + 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, contentOrder]); + }, [items, groupDefinitions, aliasToGroup, defaultContentOrder]); const selectedItem = items[selectedIndex]; return ( diff --git a/plugins/catalog/src/alpha/pages.tsx b/plugins/catalog/src/alpha/pages.tsx index 61801478f7..4d37a18c98 100644 --- a/plugins/catalog/src/alpha/pages.tsx +++ b/plugins/catalog/src/alpha/pages.tsx @@ -178,7 +178,7 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({ header={header} contextMenuItems={filteredMenuItems} groupDefinitions={groupDefinitions} - contentOrder={config.contentOrder} + defaultContentOrder={config.contentOrder} showNavItemIcons={config.showNavItemIcons} > {inputs.contents.map(output => ( From 3be516b73acb5d2d0fbeff9c603fa46cfd3bf2f4 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 26 Feb 2026 10:15:35 +0100 Subject: [PATCH 4/5] Document group aliases and content ordering configuration Add documentation for the new group aliases and contentOrder options to the catalog customization guide. Signed-off-by: Patrik Oldsberg --- .../config/vocabularies/Backstage/accept.txt | 1 + .../software-catalog/catalog-customization.md | 43 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/.github/vale/config/vocabularies/Backstage/accept.txt b/.github/vale/config/vocabularies/Backstage/accept.txt index f6f37b17cf..81ef949651 100644 --- a/.github/vale/config/vocabularies/Backstage/accept.txt +++ b/.github/vale/config/vocabularies/Backstage/accept.txt @@ -527,6 +527,7 @@ unassign unbreak Unconference undici +ungrouped unicode unmanaged unmount diff --git a/docs/features/software-catalog/catalog-customization.md b/docs/features/software-catalog/catalog-customization.md index d62bb34505..5ddcb7d5a4 100644 --- a/docs/features/software-catalog/catalog-customization.md +++ b/docs/features/software-catalog/catalog-customization.md @@ -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: From 1b32ec6a8b4e9c4e60b4dc4d89f18735b25d25e7 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 26 Feb 2026 11:48:51 +0100 Subject: [PATCH 5/5] Resolve selected tab value through alias map When the selected tab uses an aliased group ID, resolve it through the alias map so the MUI Tabs selection indicator matches the rendered group key. Signed-off-by: Patrik Oldsberg --- .../src/alpha/components/EntityTabs/EntityTabsList.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx index d647b52afb..9abbfb69d9 100644 --- a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx +++ b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx @@ -171,6 +171,11 @@ export function EntityTabsList(props: EntityTabsListProps) { }, [items, groupDefinitions, aliasToGroup, defaultContentOrder]); const selectedItem = items[selectedIndex]; + const selectedGroup = resolveGroupId( + selectedItem?.group, + groupDefinitions, + aliasToGroup, + ); return ( {groups.map(([id, tabGroup]) => (