git feat: make number of groups displayed configurable

Signed-off-by: Camila Belo <camilaibs@gmail.com>
This commit is contained in:
Camila Belo
2025-05-16 13:42:39 +02:00
parent 7f711a3906
commit 02e8af121a
7 changed files with 265 additions and 25 deletions
+21
View File
@@ -0,0 +1,21 @@
---
'@backstage/plugin-org': patch
---
Enhance user profile card configuration:
- Added a new `maxRelations` numerical configuration that controls over how many user groups are shown directly on the profile card:
- If `maxRelations` is set to `0`, the list of user groups is not displayed.
- If `maxRelations` is set to a positive number, up to that many groups are displayed.
- If the user belongs to more groups than the specified limit, a clickable link appears that opens a dialog showing all associated user groups.
- A complementary boolean configuration, `hideIcons`, was added to optionally hide the visual icons associated with each group in the displayed list.
- Usage example:
```yaml
# Example in app-config.yaml
app:
extensions:
- entity-card:org/user-profile:
config:
maxRelations: 5 # Show up to 5 groups on the card
hideIcons: true # Hide the group icons
```
+4 -1
View File
@@ -56,7 +56,10 @@ app:
- entity-card:org/group-profile
- entity-card:org/members-list
- entity-card:org/ownership
- entity-card:org/user-profile
- entity-card:org/user-profile:
config:
maxRelations: 5
hideIcons: true
# - entity-card:azure-devops/readme
# Entity page contents
+19 -3
View File
@@ -3,11 +3,13 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { AnyExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api';
import { Entity } from '@backstage/catalog-model';
import { EntityCardType } from '@backstage/plugin-catalog-react/alpha';
import { EntityPredicate } from '@backstage/plugin-catalog-react/alpha';
import { ExtensionDefinition } from '@backstage/frontend-plugin-api';
import { ExtensionInput } from '@backstage/frontend-plugin-api';
import { ExternalRouteRef } from '@backstage/frontend-plugin-api';
import { FrontendPlugin } from '@backstage/frontend-plugin-api';
import { JSX as JSX_2 } from 'react';
@@ -143,13 +145,17 @@ const _default: FrontendPlugin<
};
}>;
'entity-card:org/user-profile': ExtensionDefinition<{
kind: 'entity-card';
name: 'user-profile';
config: {
maxRelations: number | undefined;
hideIcons: boolean;
} & {
filter: EntityPredicate | undefined;
type: 'content' | 'summary' | 'info' | undefined;
};
configInput: {
hideIcons?: boolean | undefined;
maxRelations?: number | undefined;
} & {
filter?: EntityPredicate | undefined;
type?: 'content' | 'summary' | 'info' | undefined;
};
@@ -176,7 +182,17 @@ const _default: FrontendPlugin<
optional: true;
}
>;
inputs: {};
inputs: {
[x: string]: ExtensionInput<
AnyExtensionDataRef,
{
optional: boolean;
singleton: boolean;
}
>;
};
kind: 'entity-card';
name: 'user-profile';
params: {
loader: () => Promise<JSX.Element>;
filter?: string | EntityPredicate | ((entity: Entity) => boolean);
+4
View File
@@ -42,6 +42,8 @@ export type EntityRelationAggregation = 'direct' | 'aggregated';
export const EntityUserProfileCard: (props: {
variant?: InfoCardVariants;
showLinks?: boolean;
maxRelations?: number;
hideIcons?: boolean;
}) => JSX_2.Element;
// @public (undocumented)
@@ -98,5 +100,7 @@ export const OwnershipCard: (props: {
export const UserProfileCard: (props: {
variant?: InfoCardVariants;
showLinks?: boolean;
maxRelations?: number;
hideIcons?: boolean;
}) => JSX_2.Element;
```
+21 -7
View File
@@ -59,14 +59,28 @@ const EntityOwnershipCard = EntityCardBlueprint.make({
});
/** @alpha */
const EntityUserProfileCard = EntityCardBlueprint.make({
const EntityUserProfileCard = EntityCardBlueprint.makeWithOverrides({
name: 'user-profile',
params: {
filter: 'kind:user',
loader: async () =>
import('./components/Cards/User/UserProfileCard/UserProfileCard').then(
m => compatWrapper(<m.UserProfileCard />),
),
config: {
schema: {
maxRelations: z => z.number().optional(),
hideIcons: z => z.boolean().default(false),
},
},
factory(originalFactory, { config }) {
return originalFactory({
filter: 'kind:user',
loader: async () =>
import('./components/Cards/User/UserProfileCard/UserProfileCard').then(
m =>
compatWrapper(
<m.UserProfileCard
maxRelations={config.maxRelations}
hideIcons={config.hideIcons}
/>,
),
),
});
},
});
@@ -22,6 +22,7 @@ import {
import { renderInTestApp } from '@backstage/test-utils';
import { UserProfileCard } from './UserProfileCard';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
describe('UserSummary Test', () => {
const userEntity: UserEntity = {
@@ -70,6 +71,100 @@ describe('UserSummary Test', () => {
);
expect(screen.getByText('Super awesome human')).toBeInTheDocument();
});
describe('displayed user relations limit', () => {
const entity = {
...userEntity,
relations: [
{ type: 'memberOf', targetRef: 'group:default/team-a' },
{ type: 'memberOf', targetRef: 'group:default/team-b' },
{ type: 'memberOf', targetRef: 'group:default/team-c' },
{ type: 'memberOf', targetRef: 'group:default/team-d' },
{ type: 'memberOf', targetRef: 'group:default/team-e' },
],
};
it('Should limit the number of displayed groups in the card', async () => {
await renderInTestApp(
<EntityProvider entity={entity}>
<UserProfileCard variant="gridItem" maxRelations={3} />
</EntityProvider>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name': entityRouteRef,
},
},
);
// should show the first 3 groups
expect(screen.getByText('team-a')).toBeInTheDocument();
expect(screen.getByText('team-b')).toBeInTheDocument();
expect(screen.getByText('team-c')).toBeInTheDocument();
// should not show the rest
expect(screen.queryByText('team-d')).not.toBeInTheDocument();
expect(screen.queryByText('team-e')).not.toBeInTheDocument();
});
it('Show more groups button when there are more user relations than the maximum', async () => {
await renderInTestApp(
<EntityProvider entity={entity}>
<UserProfileCard variant="gridItem" maxRelations={3} />
</EntityProvider>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name': entityRouteRef,
},
},
);
// clicking the button should open a dialog listing all groups
await userEvent.click(screen.getByText('...More (2)'));
expect(screen.getByText('team-d')).toBeInTheDocument();
expect(screen.getByText('team-e')).toBeInTheDocument();
});
it('Hide more groups button when limit value is less than or equal to user relations', async () => {
await renderInTestApp(
<EntityProvider entity={entity}>
<UserProfileCard variant="gridItem" maxRelations={5} />
</EntityProvider>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name': entityRouteRef,
},
},
);
expect(screen.getByText('team-a')).toBeInTheDocument();
expect(screen.getByText('team-b')).toBeInTheDocument();
expect(screen.getByText('team-c')).toBeInTheDocument();
expect(screen.getByText('team-d')).toBeInTheDocument();
expect(screen.getByText('team-e')).toBeInTheDocument();
expect(screen.queryByText('...More (0)')).not.toBeInTheDocument();
});
it('Hide all groups when max relations is equals to zero', async () => {
await renderInTestApp(
<EntityProvider entity={entity}>
<UserProfileCard variant="gridItem" maxRelations={0} />
</EntityProvider>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name': entityRouteRef,
},
},
);
expect(screen.queryByText('team-a')).not.toBeInTheDocument();
expect(screen.queryByText('team-b')).not.toBeInTheDocument();
expect(screen.queryByText('team-c')).not.toBeInTheDocument();
expect(screen.queryByText('team-d')).not.toBeInTheDocument();
expect(screen.queryByText('team-e')).not.toBeInTheDocument();
expect(screen.queryByText('...More (5)')).not.toBeInTheDocument();
});
});
});
describe('Edit Button', () => {
@@ -25,14 +25,22 @@ import {
InfoCardVariants,
Link,
} from '@backstage/core-components';
import { makeStyles } from '@material-ui/core/styles';
import Box from '@material-ui/core/Box';
import Grid from '@material-ui/core/Grid';
import BaseButton from '@material-ui/core/ButtonBase';
import IconButton from '@material-ui/core/IconButton';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import ListItemIcon from '@material-ui/core/ListItemIcon';
import ListItemText from '@material-ui/core/ListItemText';
import Tooltip from '@material-ui/core/Tooltip';
import Button from '@material-ui/core/Button';
import Dialog from '@material-ui/core/Dialog';
import DialogTitle from '@material-ui/core/DialogTitle';
import DialogContent from '@material-ui/core/DialogContent';
import DialogActions from '@material-ui/core/DialogActions';
import CloseIcon from '@material-ui/icons/Close';
import {
EntityRefLinks,
getEntityRelations,
@@ -46,6 +54,24 @@ import GroupIcon from '@material-ui/icons/Group';
import { LinksGroup } from '../../Meta';
import PersonIcon from '@material-ui/icons/Person';
import { useCallback, useState } from 'react';
import _ from 'lodash';
const useStyles = makeStyles((theme: any) => ({
closeButton: {
position: 'absolute',
right: theme.spacing(1),
top: theme.spacing(1),
color: theme.palette.grey[500],
},
moreButton: {
display: 'contents',
color: theme.palette.primary.main,
},
dialogPaper: {
minHeight: 400,
},
}));
const CardTitle = (props: { title?: string }) =>
props.title ? (
<Box display="flex" alignItems="center">
@@ -58,8 +84,26 @@ const CardTitle = (props: { title?: string }) =>
export const UserProfileCard = (props: {
variant?: InfoCardVariants;
showLinks?: boolean;
maxRelations?: number;
hideIcons?: boolean;
}) => {
const { maxRelations, hideIcons } = props;
const classes = useStyles();
const { entity: user } = useEntity<UserEntity>();
const [isViewAllGroupsDialogOpen, setIsViewAllGroupsDialogOpen] =
useState(false);
const openViewAllGroupsDialog = useCallback(
() => setIsViewAllGroupsDialogOpen(true),
[],
);
const closeViewAllGroupsDialog = useCallback(
() => setIsViewAllGroupsDialogOpen(false),
[],
);
if (!user) {
return <Alert severity="error">User not found</Alert>;
}
@@ -117,24 +161,67 @@ export const UserProfileCard = (props: {
</ListItem>
)}
<ListItem>
<ListItemIcon>
<Tooltip title="Member of">
<GroupIcon />
</Tooltip>
</ListItemIcon>
<ListItemText>
<EntityRefLinks
entityRefs={memberOfRelations}
defaultKind="Group"
/>
</ListItemText>
</ListItem>
{maxRelations === undefined || maxRelations > 0 ? (
<ListItem>
<ListItemIcon>
<Tooltip title="Member of">
<GroupIcon />
</Tooltip>
</ListItemIcon>
<ListItemText>
<EntityRefLinks
entityRefs={memberOfRelations.slice(0, maxRelations)}
defaultKind="Group"
hideIcons={hideIcons}
/>
{maxRelations && memberOfRelations.length > maxRelations ? (
<>
,
<BaseButton
className={classes.moreButton}
onClick={openViewAllGroupsDialog}
disableRipple
>
{` ...More (${
memberOfRelations.length - maxRelations
})`}
</BaseButton>
</>
) : null}
</ListItemText>
</ListItem>
) : null}
{props?.showLinks && <LinksGroup links={links} />}
</List>
</Grid>
</Grid>
<Dialog
classes={{ paper: classes.dialogPaper }}
open={isViewAllGroupsDialogOpen}
onClose={closeViewAllGroupsDialog}
scroll="paper"
aria-labelledby="view-all-groups-dialog-title"
maxWidth="md"
fullWidth
>
<DialogTitle id="view-all-groups-dialog-title">
All {user.metadata.name}'s groups:
<IconButton
className={classes.closeButton}
aria-label="close"
onClick={closeViewAllGroupsDialog}
>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent dividers>
<EntityRefLinks entityRefs={memberOfRelations} defaultKind="Group" />
</DialogContent>
<DialogActions>
<Button onClick={closeViewAllGroupsDialog}>Close</Button>
</DialogActions>
</Dialog>
</InfoCard>
);
};