diff --git a/.changeset/mighty-sites-call.md b/.changeset/mighty-sites-call.md new file mode 100644 index 0000000000..33eca1f2e3 --- /dev/null +++ b/.changeset/mighty-sites-call.md @@ -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 + ``` diff --git a/packages/app-next/app-config.yaml b/packages/app-next/app-config.yaml index 9d2bcf198a..0e6dd0a356 100644 --- a/packages/app-next/app-config.yaml +++ b/packages/app-next/app-config.yaml @@ -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 diff --git a/plugins/org/report-alpha.api.md b/plugins/org/report-alpha.api.md index e5d574ed5f..55397c259c 100644 --- a/plugins/org/report-alpha.api.md +++ b/plugins/org/report-alpha.api.md @@ -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; filter?: string | EntityPredicate | ((entity: Entity) => boolean); diff --git a/plugins/org/report.api.md b/plugins/org/report.api.md index d3e82478f7..906e43a785 100644 --- a/plugins/org/report.api.md +++ b/plugins/org/report.api.md @@ -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; ``` diff --git a/plugins/org/src/alpha.tsx b/plugins/org/src/alpha.tsx index beb4f573af..46f07a8cf0 100644 --- a/plugins/org/src/alpha.tsx +++ b/plugins/org/src/alpha.tsx @@ -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(), - ), + 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( + , + ), + ), + }); }, }); diff --git a/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.test.tsx b/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.test.tsx index d8537e217e..b5932dfb8e 100644 --- a/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.test.tsx +++ b/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.test.tsx @@ -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( + + + , + { + 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( + + + , + { + 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( + + + , + { + 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( + + + , + { + 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', () => { diff --git a/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.tsx b/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.tsx index 77188c6fb4..0d3d02d0a2 100644 --- a/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.tsx +++ b/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.tsx @@ -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 ? ( @@ -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(); + const [isViewAllGroupsDialogOpen, setIsViewAllGroupsDialogOpen] = + useState(false); + + const openViewAllGroupsDialog = useCallback( + () => setIsViewAllGroupsDialogOpen(true), + [], + ); + + const closeViewAllGroupsDialog = useCallback( + () => setIsViewAllGroupsDialogOpen(false), + [], + ); + if (!user) { return User not found; } @@ -117,24 +161,67 @@ export const UserProfileCard = (props: { )} - - - - - - - - - - - + {maxRelations === undefined || maxRelations > 0 ? ( + + + + + + + + + {maxRelations && memberOfRelations.length > maxRelations ? ( + <> + , + + {` ...More (${ + memberOfRelations.length - maxRelations + })`} + + + ) : null} + + + ) : null} {props?.showLinks && } + + + + All {user.metadata.name}'s groups: + + + + + + + + + + + ); };