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 => (