Merge pull request #29043 from backstage/camilaibs/entity-page-groups-followups
[NFS] Entity Page Group Sorting
This commit is contained in:
@@ -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'
|
||||
```
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
} & {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user