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..c2a17ae773 --- /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 `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/.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/.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: diff --git a/packages/app/app-config.yaml b/packages/app/app-config.yaml index 0fef43fbb5..0e49365c08 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 '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 diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index acd85b5540..9c76a221dc 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -345,6 +345,8 @@ export type EntityContentGroupDefinitions = Record< { title: string; icon?: string | ReactElement; + aliases?: string[]; + contentOrder?: 'title' | 'natural'; } >; diff --git a/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx b/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx index 83b9aa983b..04c13db484 100644 --- a/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx @@ -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'; } >; diff --git a/plugins/catalog/report-alpha.api.md b/plugins/catalog/report-alpha.api.md index c2d856561f..3aae7d8920 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?: '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; }; diff --git a/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx b/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx index ee6e2fc012..895f36150f 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?: '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) => { )} diff --git a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.tsx b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.tsx index ed737266a5..102df76042 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?: '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} /> diff --git a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx index 9267e083f5..9abbfb69d9 100644 --- a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx +++ b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx @@ -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 | 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), + [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); 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 ( {groups.map(([id, tabGroup]) => ( { 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), diff --git a/plugins/catalog/src/alpha/pages.tsx b/plugins/catalog/src/alpha/pages.tsx index acb9ec8d51..4d37a18c98 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(['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 => (