Merge pull request #33019 from backstage/rugvip/nfs-entity-page-group-aliases-and-ordering
catalog: add group aliases and configurable content ordering to entity page
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -527,6 +527,7 @@ unassign
|
||||
unbreak
|
||||
Unconference
|
||||
undici
|
||||
ungrouped
|
||||
unicode
|
||||
unmanaged
|
||||
unmount
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -345,6 +345,8 @@ export type EntityContentGroupDefinitions = Record<
|
||||
{
|
||||
title: string;
|
||||
icon?: string | ReactElement;
|
||||
aliases?: string[];
|
||||
contentOrder?: 'title' | 'natural';
|
||||
}
|
||||
>;
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
>;
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
<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?: '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}
|
||||
/>
|
||||
<EntityTabsPanel>
|
||||
<Helmet title={route?.title} />
|
||||
|
||||
@@ -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, string>,
|
||||
): 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<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 = 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<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,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 (
|
||||
<Box className={styles.tabsWrapper}>
|
||||
<Tabs
|
||||
@@ -127,7 +185,7 @@ export function EntityTabsList(props: EntityTabsListProps) {
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
aria-label={t('entityTabs.tabsAriaLabel')}
|
||||
value={selectedItem?.group ?? selectedItem?.id}
|
||||
value={selectedGroup ?? selectedItem?.id}
|
||||
>
|
||||
{groups.map(([id, tabGroup]) => (
|
||||
<EntityTabsGroup
|
||||
|
||||
@@ -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 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),
|
||||
|
||||
@@ -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 => (
|
||||
|
||||
Reference in New Issue
Block a user