Merge pull request #29043 from backstage/camilaibs/entity-page-groups-followups

[NFS] Entity Page Group Sorting
This commit is contained in:
Camila Belo
2025-03-05 09:11:48 +01:00
committed by GitHub
5 changed files with 186 additions and 104 deletions
+48
View File
@@ -0,0 +1,48 @@
---
'@backstage/plugin-catalog': minor
---
The order in which group tabs appear on the entity page has been changed.
### Before
Previously, entity contents determined the order in which groups were rendered, so a group was rendered as soon as its first entity content was detected.
### After
Groups are now rendered first by default based on their order in the `app-config.yaml` file:
```diff
app:
extensions:
- page:catalog/entity:
+ config:
+ groups:
+ # this will be the first tab of the default entity page
+ - deployment:
+ title: Deployment
+ # this will be the second tab of the default entiy page
+ - documentation:
+ title: Documentation
```
If you wish to place a normal tab before a group, you must add the tab to a group and place the group in the order you wish it to appear on the entity page (groups that contains only one tab are rendered as normal tabs).
```diff
app:
extensions:
- page:catalog/entity:
config:
groups:
+ # Example placing the overview tab first
+ - overview:
+ title: Overview
- deployment:
title: Deployment
# this will be the second tab of the default entiy page
- documentation:
title: Documentation
- entity-content:catalog/overview:
+ config:
+ group: 'overview'
```
+8 -4
View File
@@ -16,11 +16,16 @@ app:
- page:catalog/entity:
config:
groups:
# placing a tab at the beginning
- overview:
title: Overview
# example disabling a default group
- development: false
# - development: false
# example overriding a default group title
- documentation:
title: Docs
title: Docs 2
- deployment:
title: Deployments
# example adding a new group
- custom:
title: Custom
@@ -53,8 +58,7 @@ app:
# Entity page contents
- entity-content:catalog/overview:
config:
# associating with a custom group
group: custom
group: overview
- entity-content:api-docs/definition
- entity-content:api-docs/apis:
config:
+6 -8
View File
@@ -940,10 +940,9 @@ const _default: FrontendPlugin<
groups:
| Record<
string,
| false
| {
title: string;
}
{
title: string;
}
>[]
| undefined;
} & {
@@ -953,10 +952,9 @@ const _default: FrontendPlugin<
groups?:
| Record<
string,
| false
| {
title: string;
}
{
title: string;
}
>[]
| undefined;
} & {
+88 -56
View File
@@ -114,7 +114,6 @@ describe('Index page', () => {
params: {
defaultPath: '/overview',
defaultTitle: 'Overview',
defaultGroup: 'documentation',
loader: async () => <div>Mock Overview content</div>,
},
});
@@ -254,61 +253,6 @@ describe('Index page', () => {
);
});
it('Should disable a default group', async () => {
const tester = createExtensionTester(
Object.assign({ namespace: 'catalog' }, catalogEntityPage),
{
config: {
groups: [
{
documentation: false,
},
],
},
},
)
.add(techdocsEntityContent)
.add(apidocsEntityContent);
await renderInTestApp(
<TestApiProvider
apis={[
[catalogApiRef, mockCatalogApi],
[starredEntitiesApiRef, mockStarredEntitiesApi],
]}
>
{tester.reactElement()}
</TestApiProvider>,
{
config: {
app: {
title: 'Custom app',
},
backend: { baseUrl: 'http://localhost:7000' },
},
mountedRoutes: {
'/catalog': convertLegacyRouteRef(rootRouteRef),
'/catalog/:namespace/:kind/:name':
convertLegacyRouteRef(entityRouteRef),
},
},
);
await waitFor(() =>
expect(
screen.queryByRole('tab', { name: /Documentation/ }),
).not.toBeInTheDocument(),
);
await waitFor(() =>
expect(screen.getByRole('tab', { name: /TechDocs/ })).toBeInTheDocument(),
);
await waitFor(() =>
expect(screen.getByRole('tab', { name: /ApiDocs/ })).toBeInTheDocument(),
);
});
it('Should disassociate a content with a default group', async () => {
const tester = createExtensionTester(
Object.assign({ namespace: 'catalog' }, catalogEntityPage),
@@ -474,4 +418,92 @@ describe('Index page', () => {
).not.toBeInTheDocument(),
);
});
it('Should render groups first', async () => {
const tester = createExtensionTester(
Object.assign({ namespace: 'catalog' }, catalogEntityPage),
)
.add(techdocsEntityContent)
.add(apidocsEntityContent)
.add(overviewEntityContent);
await renderInTestApp(
<TestApiProvider
apis={[
[catalogApiRef, mockCatalogApi],
[starredEntitiesApiRef, mockStarredEntitiesApi],
]}
>
{tester.reactElement()}
</TestApiProvider>,
{
config: {
app: {
title: 'Custom app',
},
backend: { baseUrl: 'http://localhost:7000' },
},
mountedRoutes: {
'/catalog': convertLegacyRouteRef(rootRouteRef),
'/catalog/:namespace/:kind/:name':
convertLegacyRouteRef(entityRouteRef),
},
},
);
await waitFor(() => expect(screen.getAllByRole('tab')).toHaveLength(2));
expect(screen.getAllByRole('tab')[0]).toHaveTextContent('Documentation');
expect(screen.getAllByRole('tab')[1]).toHaveTextContent('Overview');
});
it('Should render groups on the correct order', async () => {
const tester = createExtensionTester(
Object.assign({ namespace: 'catalog' }, catalogEntityPage),
{
config: {
groups: [
{ overview: { title: 'Overview' } },
{ documentation: { title: 'Documentation' } },
],
},
},
)
.add(techdocsEntityContent)
.add(apidocsEntityContent)
.add(overviewEntityContent, {
config: {
group: 'overview',
},
});
await renderInTestApp(
<TestApiProvider
apis={[
[catalogApiRef, mockCatalogApi],
[starredEntitiesApiRef, mockStarredEntitiesApi],
]}
>
{tester.reactElement()}
</TestApiProvider>,
{
config: {
app: {
title: 'Custom app',
},
backend: { baseUrl: 'http://localhost:7000' },
},
mountedRoutes: {
'/catalog': convertLegacyRouteRef(rootRouteRef),
'/catalog/:namespace/:kind/:name':
convertLegacyRouteRef(entityRouteRef),
},
},
);
await waitFor(() => expect(screen.getAllByRole('tab')).toHaveLength(2));
expect(screen.getAllByRole('tab')[0]).toHaveTextContent('Overview');
expect(screen.getAllByRole('tab')[1]).toHaveTextContent('Documentation');
});
});
+36 -36
View File
@@ -72,12 +72,7 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({
schema: {
groups: z =>
z
.array(
z.record(
z.string(),
z.literal(false).or(z.object({ title: z.string() })),
),
)
.array(z.record(z.string(), z.object({ title: z.string() })))
.optional(),
},
},
@@ -88,48 +83,53 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({
loader: async () => {
const { EntityLayout } = await import('./components/EntityLayout');
// config groups override default groups
const groups: Record<string, string> = config.groups?.length
? config.groups.reduce<Record<string, string>>((rest, group) => {
const [groupId, groupValue] = Object.entries(group)[0];
return groupValue
? {
...rest,
[groupId]: groupValue.title,
}
: rest;
}, {})
: defaultEntityContentGroups;
type Groups = Record<
string,
{ title: string; items: Array<(typeof inputs.contents)[0]> }
>;
// the groups order is determined by the order of the contents
// a group will appear in the order of the first item that belongs to it
const tabs = inputs.contents.reduce<
Record<string, Array<(typeof inputs.contents)[0]>>
>((rest, output) => {
const itemTitle = output.get(EntityContentBlueprint.dataRefs.title);
const groupId = output.get(EntityContentBlueprint.dataRefs.group);
const groupTitle = groupId && groups[groupId];
// disabled or invalid groups are ignored
if (!groupTitle) {
let groups = Object.entries(defaultEntityContentGroups).reduce<Groups>(
(rest, group) => {
const [groupId, groupValue] = group;
return {
...rest,
[itemTitle]: [output],
[groupId]: { title: groupValue, items: [] },
};
},
{},
);
// config groups override default groups
if (config.groups) {
groups = config.groups.reduce<Groups>((rest, group) => {
const [groupId, groupValue] = Object.entries(group)[0];
return {
...rest,
[groupId]: { title: groupValue.title, items: [] },
};
}, {});
}
for (const output of inputs.contents) {
const itemId = output.node.spec.id;
const itemTitle = output.get(EntityContentBlueprint.dataRefs.title);
const itemGroup = output.get(EntityContentBlueprint.dataRefs.group);
const group = itemGroup && groups[itemGroup];
if (!group) {
groups[itemId] = { title: itemTitle, items: [output] };
continue;
}
return {
...rest,
[groupTitle]: [...(rest[groupTitle] ?? []), output],
};
}, {});
group.items.push(output);
}
const Component = () => {
return (
<AsyncEntityProvider {...useEntityFromUrl()}>
<EntityLayout>
{Object.entries(tabs).flatMap(([group, items]) =>
{Object.values(groups).flatMap(({ title, items }) =>
items.map(output => (
<EntityLayout.Route
group={group}
group={title}
key={output.get(coreExtensionData.routePath)}
path={output.get(coreExtensionData.routePath)}
title={output.get(EntityContentBlueprint.dataRefs.title)}