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 <poldsberg@gmail.com>
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 `alpha`) 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,6 +24,7 @@ export { EntityHeaderBlueprint } from './EntityHeaderBlueprint';
|
||||
export {
|
||||
defaultEntityContentGroups,
|
||||
defaultEntityContentGroupDefinitions,
|
||||
type EntityContentGroupDefinition,
|
||||
type EntityContentGroupDefinitions,
|
||||
} from './extensionData';
|
||||
export type { EntityCardType } from './extensionData';
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
<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?: '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}
|
||||
/>
|
||||
<EntityTabsPanel>
|
||||
<Helmet title={route?.title} />
|
||||
|
||||
@@ -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<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 = 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<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,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 (
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 => (
|
||||
|
||||
Reference in New Issue
Block a user