1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-05-12 01:17:04 +02:00

Wip: split out avatar group; use same tooltip for all avatars (#7681)

Extracts the Avatar Group component into a `common` component and adds a
standard tooltip to all avatars.

Relates to linear issue 1-2606

This is a suggestion / proof of concept for how we can solve it. While I
think we can merge this as is, I'd also be happy to take any discussions
on other ways to approach it etc.

## Why are these changes made together?

Because extracting the avatar group without adding the new tooltip data
made the existing tooltip misbehave (it'd show up in the top left of the
screen, not synced to the avatar in any way).

I probably could have (and still can if you think it's prudent) split it
out such that the avatar gets a standardized tooltip first (and disable
it for the group card avatars), and split out the avatars in a
follow-up. Happy to do that if you think it's better.

## What does this mean? 

It used to be that we had no consistent way of dealing with avatars and
tooltips. Some places had them, some places didn't. This change makes it
so that all avatars that we can show tooltips for will get the same
tooltip.

Previously, we had at least 4 different ways of dealing with tooltips:
- The HTML tooltip (that would be standardized with this PR) in the
project flags table

![image](https://github.com/user-attachments/assets/91098d31-a5e3-4091-9125-332fe5d106fd)
- The "title" that you'd get on your user avatar

![image](https://github.com/user-attachments/assets/39062b61-db8c-4bd5-9fa3-3ecc9bc192ee)
- The group card list tooltip

![image](https://github.com/user-attachments/assets/0d4a696a-e944-446c-8bff-4dcec02d8afb)
- And sometimes you'd get nothing at all

![image](https://github.com/user-attachments/assets/8975afaf-9ca1-4eb6-b443-9ab94b52bbd8)

with this change, we'll always show the same kind of tooltip if we can:


![image](https://github.com/user-attachments/assets/974c592c-c844-4b65-8a55-05e84d3df130)

## What goes in the tooltip? 

We use the `UserAvatar` component for a fair few different things and I
didn't want to extract separate components for all the different use
cases. Instead, I wanted to get an overview over what we use it for and
what is relevant info to show.

I found all the places we used it and tried to form an opinion. 

This tooltip will work with a user's email, name, username, and id. If
there is no user (such as for empty avatars and avatars displaying only
"+n" for remaining members), we show no tooltip.

Following the example set by the group card avatars, we'll try to use
email or username (in that order) as the main bit of text. If the user
has an email or a username and also a name, the name will be used as
secondary text.

If the user does not have an email or username, but has a name, we'll
use the name as the main text.

If the user does not have an email, a username, or a name, we'll try to
show "User ID: N" if they have an id.

If they do not have a username, a name, an email, or an ID, we bail out
and show nothing.

## Why can you disable the tooltip?

In some cases, you might want to disable the tooltip because you have
more information to feed into it. An example of that is in the project
flags table, where we want to show more information in cases where the
user is 'unknown':


![image](https://github.com/user-attachments/assets/758b4e86-e934-47e3-91ce-ce900f76bc54)


## Additional fixes

This PR also adds a few lines of CSS to fix a minor avatar layout bug.

Before:

![image](https://github.com/user-attachments/assets/0150efbf-c51a-40bb-898f-7ddd3565ce21)


After:

![image](https://github.com/user-attachments/assets/f337cf68-c572-4610-b1de-a27749325da8)
This commit is contained in:
Thomas Heartman 2024-08-01 10:45:24 +02:00 committed by GitHub
parent 0c53f7d21b
commit f1e95108d1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 179 additions and 207 deletions

View File

@ -7,7 +7,7 @@ import { GroupCardActions } from './GroupCardActions/GroupCardActions';
import TopicOutlinedIcon from '@mui/icons-material/TopicOutlined';
import { RoleBadge } from 'component/common/RoleBadge/RoleBadge';
import { useScimSettings } from 'hooks/api/getters/useScimSettings/useScimSettings';
import { GroupCardAvatars } from './GroupCardAvatars/NewGroupCardAvatars';
import { AvatarGroup } from 'component/common/AvatarGroup/AvatarGroup';
const StyledLink = styled(Link)(({ theme }) => ({
textDecoration: 'none',
@ -132,7 +132,7 @@ export const GroupCard = ({
<StyledBottomRow>
<ConditionallyRender
condition={group.users?.length > 0}
show={<GroupCardAvatars users={group.users} />}
show={<AvatarGroup users={group.users} />}
elseShow={
<StyledCounterDescription>
This group has no users.

View File

@ -1,143 +0,0 @@
import { styled } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import type { IGroupUser } from 'interfaces/group';
import type React from 'react';
import { type ReactNode, useMemo, useState } from 'react';
import { GroupPopover } from './GroupPopover/GroupPopover';
import { UserAvatar } from 'component/common/UserAvatar/UserAvatar';
import { objectId } from 'utils/objectId';
const StyledContainer = styled('div')(() => ({
display: 'flex',
flexDirection: 'column',
}));
const StyledAvatars = styled('div')(({ theme }) => ({
display: 'inline-flex',
alignItems: 'center',
flexWrap: 'wrap',
marginLeft: theme.spacing(1),
}));
const StyledAvatar = (component: typeof UserAvatar) =>
styled(component)(({ theme }) => ({
outline: `${theme.spacing(0.25)} solid ${theme.palette.background.paper}`,
marginLeft: theme.spacing(-1),
'&:hover': {
outlineColor: theme.palette.primary.main,
},
}));
const StyledHeader = styled('h3')(({ theme }) => ({
margin: theme.spacing(0, 0, 1),
fontSize: theme.typography.caption.fontSize,
color: theme.palette.text.primary,
fontWeight: theme.typography.fontWeightRegular,
}));
interface IGroupCardAvatarsProps {
users: {
name: string;
description?: string;
imageUrl?: string;
}[];
header?: ReactNode;
avatarLimit?: number;
AvatarComponent?: typeof UserAvatar;
}
export const GroupCardAvatars = ({
AvatarComponent,
...props
}: IGroupCardAvatarsProps) => {
const Avatar = StyledAvatar(AvatarComponent ?? UserAvatar);
return <GroupCardAvatarsInner AvatarComponent={Avatar} {...props} />;
};
type GroupCardAvatarsInnerProps = Omit<
IGroupCardAvatarsProps,
'AvatarComponent'
> & {
AvatarComponent: typeof UserAvatar;
};
const GroupCardAvatarsInner = ({
users = [],
header = null,
avatarLimit = 9,
AvatarComponent,
}: GroupCardAvatarsInnerProps) => {
const shownUsers = useMemo(
() =>
users
.sort((a, b) => {
if (
Object.hasOwn(a, 'joinedAt') &&
Object.hasOwn(b, 'joinedAt')
) {
return (
(b as IGroupUser)?.joinedAt!.getTime() -
(a as IGroupUser)?.joinedAt!.getTime()
);
}
return 0;
})
.slice(0, avatarLimit),
[users],
);
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [popupUser, setPopupUser] = useState<{
name: string;
description?: string;
imageUrl?: string;
}>();
const onPopoverOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const onPopoverClose = () => {
setAnchorEl(null);
};
const avatarOpen = Boolean(anchorEl);
return (
<StyledContainer>
<ConditionallyRender
condition={typeof header === 'string'}
show={<StyledHeader>{header}</StyledHeader>}
elseShow={header}
/>
<StyledAvatars>
{shownUsers.map((user) => (
<AvatarComponent
key={objectId(user)}
user={{ ...user, id: objectId(user) }}
onMouseEnter={(event) => {
onPopoverOpen(event);
setPopupUser(user);
}}
onMouseLeave={onPopoverClose}
/>
))}
<ConditionallyRender
condition={users.length > avatarLimit}
show={
<AvatarComponent>
+{users.length - shownUsers.length}
</AvatarComponent>
}
/>
<GroupPopover
open={avatarOpen}
user={popupUser}
anchorEl={anchorEl}
onPopoverClose={onPopoverClose}
/>
</StyledAvatars>
</StyledContainer>
);
};

View File

@ -1,19 +1,12 @@
import { Dialogue } from 'component/common/Dialogue/Dialogue';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { REMOVE_USER_ERROR } from 'hooks/api/actions/useAdminUsersApi/useAdminUsersApi';
import { Alert, styled } from '@mui/material';
import { Alert } from '@mui/material';
import useLoading from 'hooks/useLoading';
import { Typography } from '@mui/material';
import { useThemeStyles } from 'themes/themeStyles';
import { UserAvatar } from 'component/common/UserAvatar/UserAvatar';
import type { IInactiveUser } from '../../../../../hooks/api/getters/useInactiveUsers/useInactiveUsers';
const StyledUserAvatar = styled(UserAvatar)(({ theme }) => ({
width: theme.spacing(5),
height: theme.spacing(5),
margin: 0,
}));
interface IDeleteUserProps {
showDialog: boolean;
closeDialog: () => void;

View File

@ -0,0 +1,92 @@
import { styled } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import type { IGroupUser } from 'interfaces/group';
import { useMemo } from 'react';
import { UserAvatar } from 'component/common/UserAvatar/UserAvatar'; // usage
import { objectId } from 'utils/objectId';
const StyledAvatars = styled('div')(({ theme }) => ({
display: 'inline-flex',
alignItems: 'center',
flexWrap: 'wrap',
marginLeft: theme.spacing(1),
justifyContent: 'start',
}));
const StyledAvatar = (component: typeof UserAvatar) =>
styled(component)(({ theme }) => ({
outline: `${theme.spacing(0.25)} solid ${theme.palette.background.paper}`,
margin: 0,
marginLeft: theme.spacing(-1),
'&:hover': {
outlineColor: theme.palette.primary.main,
},
}));
type User = {
name: string;
description?: string;
imageUrl?: string;
};
type AvatarGroupProps = {
users: User[];
avatarLimit?: number;
AvatarComponent?: typeof UserAvatar;
};
export const AvatarGroup = ({
AvatarComponent,
...props
}: AvatarGroupProps) => {
const Avatar = StyledAvatar(AvatarComponent ?? UserAvatar);
return <GroupCardAvatarsInner AvatarComponent={Avatar} {...props} />;
};
type GroupCardAvatarsInnerProps = Omit<AvatarGroupProps, 'AvatarComponent'> & {
AvatarComponent: typeof UserAvatar;
};
const GroupCardAvatarsInner = ({
users = [],
avatarLimit = 9,
AvatarComponent,
}: GroupCardAvatarsInnerProps) => {
const shownUsers = useMemo(
() =>
users
.sort((a, b) => {
if (
Object.hasOwn(a, 'joinedAt') &&
Object.hasOwn(b, 'joinedAt')
) {
return (
(b as IGroupUser)?.joinedAt!.getTime() -
(a as IGroupUser)?.joinedAt!.getTime()
);
}
return 0;
})
.slice(0, avatarLimit),
[users],
);
return (
<StyledAvatars>
{shownUsers.map((user) => (
<AvatarComponent
key={objectId(user)}
user={{ ...user, id: objectId(user) }}
/>
))}
<ConditionallyRender
condition={users.length > avatarLimit}
show={
<AvatarComponent>
+{users.length - shownUsers.length}
</AvatarComponent>
}
/>
</StyledAvatars>
);
};

View File

@ -8,7 +8,7 @@ import {
import type { IUser } from 'interfaces/user';
import type { FC } from 'react';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { HtmlTooltip } from '../HtmlTooltip/HtmlTooltip';
const StyledAvatar = styled(Avatar)(({ theme }) => ({
width: theme.spacing(3.5),
height: theme.spacing(3.5),
@ -24,36 +24,51 @@ export interface IUserAvatarProps extends AvatarProps {
Pick<IUser, 'id' | 'name' | 'email' | 'username' | 'imageUrl'>
>;
src?: string;
title?: string;
onMouseEnter?: (event: any) => void;
onMouseLeave?: () => void;
className?: string;
sx?: SxProps<Theme>;
hideTitle?: boolean;
disableTooltip?: boolean;
}
const tooltipContent = (
user: IUserAvatarProps['user'],
): { main: string; secondary?: string } | undefined => {
if (!user) {
return undefined;
}
const [mainIdentifier, secondaryInfo] = [
user.email || user.username,
user.name,
];
if (mainIdentifier) {
return { main: mainIdentifier, secondary: secondaryInfo };
} else if (secondaryInfo) {
return { main: secondaryInfo };
} else if (user.id) {
return { main: `User ID: ${user.id}` };
}
return undefined;
};
const TooltipSecondaryContent = styled('div')(({ theme }) => ({
color: theme.palette.text.secondary,
fontSize: theme.typography.body2.fontSize,
}));
const TooltipMainContent = styled('div')(({ theme }) => ({
fontSize: theme.typography.body1.fontSize,
}));
export const UserAvatar: FC<IUserAvatarProps> = ({
user,
src,
title,
onMouseEnter,
onMouseLeave,
className,
sx,
children,
hideTitle,
disableTooltip,
...props
}) => {
if (!hideTitle && !title && !onMouseEnter && user) {
title = `${user?.name || user?.email || user?.username} (id: ${
user?.id
})`;
}
if (!src && user) {
src = user?.imageUrl;
}
let fallback: string | undefined;
if (!children && user) {
fallback = user?.name || user?.email || user?.username;
@ -66,17 +81,14 @@ export const UserAvatar: FC<IUserAvatarProps> = ({
}
}
return (
const Avatar = (
<StyledAvatar
className={className}
sx={sx}
{...props}
data-loading
alt={user?.name || user?.email || user?.username || 'Gravatar'}
src={src}
title={title}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
src={src || user?.imageUrl}
>
<ConditionallyRender
condition={Boolean(fallback)}
@ -85,4 +97,26 @@ export const UserAvatar: FC<IUserAvatarProps> = ({
/>
</StyledAvatar>
);
const tooltip = disableTooltip ? undefined : tooltipContent(user);
if (tooltip) {
return (
<HtmlTooltip
arrow
describeChild
title={
<>
<TooltipSecondaryContent>
{tooltip.secondary}
</TooltipSecondaryContent>
<TooltipMainContent>{tooltip.main}</TooltipMainContent>
</>
}
>
{Avatar}
</HtmlTooltip>
);
}
return Avatar;
};

View File

@ -1,6 +1,5 @@
import { styled } from '@mui/material';
import { GroupCardAvatars } from 'component/admin/groups/GroupsList/GroupCard/GroupCardAvatars/NewGroupCardAvatars';
import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip';
import { AvatarGroup } from 'component/common/AvatarGroup/AvatarGroup';
import { UserAvatar } from 'component/common/UserAvatar/UserAvatar';
import type { Collaborator } from 'interfaces/featureToggle';
import type { FC } from 'react';
@ -30,11 +29,7 @@ const LastModifiedBy: FC<Collaborator> = ({ id, name, imageUrl }) => {
<SectionContainer>
<span>Last modified by</span>
<LastModifiedByAvatarAndLink>
<HtmlTooltip arrow describeChild title={name}>
<span>
<StyledAvatar user={{ id, name, imageUrl }} hideTitle />
</span>
</HtmlTooltip>
<StyledAvatar user={{ id, name, imageUrl }} />
<Link to='logs'>view change</Link>
</LastModifiedByAvatarAndLink>
</SectionContainer>
@ -47,7 +42,7 @@ const CollaboratorList: FC<{ collaborators: Collaborator[] }> = ({
return (
<SectionContainer>
<span className='description'>Collaborators</span>
<GroupCardAvatars
<AvatarGroup
users={collaborators}
avatarLimit={8}
AvatarComponent={StyledAvatar}

View File

@ -236,7 +236,7 @@ const FeatureOverviewMetaData = () => {
<span>{feature.createdBy?.name}</span>
</StyledDetail>
<StyledUserAvatar
src={feature.createdBy?.imageUrl}
user={feature.createdBy}
/>
</StyledDetailsContainer>
</BodyItemWithIcon>

View File

@ -43,8 +43,6 @@ import { FeatureArchiveDialog } from 'component/common/FeatureArchiveDialog/Feat
import { FeatureArchiveNotAllowedDialog } from 'component/common/FeatureArchiveDialog/FeatureArchiveNotAllowedDialog';
import { useFavoriteFeaturesApi } from 'hooks/api/actions/useFavoriteFeaturesApi/useFavoriteFeaturesApi';
import { FavoriteIconButton } from 'component/common/FavoriteIconButton/FavoriteIconButton';
import { ReactComponent as ChildLinkIcon } from 'assets/icons/link-child.svg';
import { ReactComponent as ParentLinkIcon } from 'assets/icons/link-parent.svg';
import { ChildrenTooltip } from './FeatureOverview/FeatureOverviewMetaData/ChildrenTooltip';
import copy from 'copy-to-clipboard';
import useToast from 'hooks/useToast';
@ -85,16 +83,6 @@ const StyledDependency = styled('div')(({ theme }) => ({
width: 'max-content',
}));
const StyleChildLinkIcon = styled(ChildLinkIcon)(({ theme }) => ({
width: theme.fontSizes.smallBody,
height: theme.fontSizes.smallBody,
}));
const StyledParentLinkIcon = styled(ParentLinkIcon)(({ theme }) => ({
width: theme.fontSizes.smallBody,
height: theme.fontSizes.smallBody,
}));
const StyledFeatureViewHeader = styled('h1')(({ theme }) => ({
fontSize: theme.fontSizes.mainHeader,
fontWeight: 'normal',

View File

@ -1,9 +1,9 @@
import type { FC } from 'react';
import { styled } from '@mui/material';
import { GroupCardAvatars } from 'component/admin/groups/GroupsList/GroupCard/GroupCardAvatars/NewGroupCardAvatars';
import type { ProjectSchema, ProjectSchemaOwners } from 'openapi';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { AvatarGroup } from 'component/common/AvatarGroup/AvatarGroup';
interface IProjectOwnersProps {
owners?: ProjectSchema['owners'];
@ -29,7 +29,7 @@ const useOwnersMap = () => {
}
if (owner.ownerType === 'group') {
return {
name: owner.name || '',
name: owner.name,
description: 'group',
};
}
@ -50,17 +50,30 @@ const StyledUserName = styled('p')(({ theme }) => ({
alignSelf: 'end',
}));
const StyledContainer = styled('div')(() => ({
display: 'flex',
flexDirection: 'column',
}));
const StyledHeader = styled('h3')(({ theme }) => ({
margin: theme.spacing(0, 0, 1),
fontSize: theme.typography.caption.fontSize,
color: theme.palette.text.primary,
fontWeight: theme.typography.fontWeightRegular,
}));
export const ProjectOwners: FC<IProjectOwnersProps> = ({ owners = [] }) => {
const ownersMap = useOwnersMap();
const users = owners.map(ownersMap);
return (
<>
<GroupCardAvatars
header={owners.length === 1 ? 'Owner' : 'Owners'}
users={users}
avatarLimit={8}
/>
<StyledContainer>
<StyledHeader>
{owners.length === 1 ? 'Owner' : 'Owners'}
</StyledHeader>
<AvatarGroup users={users} avatarLimit={8} />
</StyledContainer>
<ConditionallyRender
condition={owners.length === 1}
show={

View File

@ -73,7 +73,7 @@ export const AvatarCell =
</ScreenReaderOnly>
<StyledAvatar
hideTitle
disableTooltip
user={{
id: original.createdBy.id,
name: original.createdBy.name,

View File

@ -71,7 +71,7 @@ export interface IFeatureToggle {
lifecycle?: Lifecycle;
children: Array<string>;
createdBy?: {
id: string;
id: number;
name: string;
imageUrl: string;
};