0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat(console): add hover tooltip for micro user avatars (#3861)

This commit is contained in:
Charles Zhao 2023-05-23 16:30:45 +08:00 committed by GitHub
parent 0ebaec520e
commit c6821aab77
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 102 additions and 40 deletions

View file

@ -1,5 +1,4 @@
import type { User } from '@logto/schemas';
import { useTranslation } from 'react-i18next';
import Checkbox from '@/components/Checkbox';
import UserAvatar from '@/components/UserAvatar';
@ -16,8 +15,6 @@ type Props = {
};
function SourceUserItem({ user, isSelected, onSelect }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
return (
<div
role="button"
@ -36,8 +33,8 @@ function SourceUserItem({ user, isSelected, onSelect }: Props) {
onSelect();
}}
/>
<UserAvatar user={user} size="micro" />
<div className={styles.title}>{getUserTitle(user) ?? t('users.unnamed')}</div>
<UserAvatar hasTooltip user={user} size="micro" />
<div className={styles.title}>{getUserTitle(user)}</div>
{user.isSuspended && <SuspendedTag className={styles.suspended} />}
</div>
);

View file

@ -1,5 +1,4 @@
import type { User } from '@logto/schemas';
import { useTranslation } from 'react-i18next';
import Close from '@/assets/images/close.svg';
import IconButton from '@/components/IconButton';
@ -15,13 +14,11 @@ type Props = {
};
function TargetUserItem({ user, onDelete }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
return (
<div className={styles.item}>
<div className={styles.meta}>
<UserAvatar user={user} size="micro" />
<div className={styles.title}>{getUserTitle(user) ?? t('users.unnamed')}</div>
<UserAvatar hasTooltip user={user} size="micro" />
<div className={styles.title}>{getUserTitle(user)}</div>
{user.isSuspended && <SuspendedTag className={styles.suspended} />}
</div>
<IconButton

View file

@ -9,6 +9,7 @@
padding: _.unit(2) _.unit(3);
font: var(--font-body-2);
max-width: 300px;
z-index: 200;
&.successful {
background: var(--color-success-60);

View file

@ -1,3 +1,5 @@
@use '@/scss/underscore' as _;
.wrapper {
position: relative;
width: 48px;
@ -65,3 +67,29 @@
transform: scale(1.25);
}
}
.tooltip {
padding: _.unit(2.5);
font: var(--font-body-2);
.row {
display: flex;
align-items: center;
white-space: nowrap;
+ .row {
margin-top: _.unit(1);
}
}
.label {
// Fixed font color should used in Tooltip component as the color does not change when theme changes.
color: #a9acac;
}
.value {
// Fixed font color should used in Tooltip component as the color does not change when theme changes.
color: #f7f8f8;
margin-left: _.unit(1);
}
}

View file

@ -1,20 +1,52 @@
import type { User } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import DefaultAvatar from '@/assets/images/default-avatar.svg';
import { formatToInternationalPhoneNumber } from '@/utils/phone';
import ImageWithErrorFallback from '../ImageWithErrorFallback';
import { Tooltip } from '../Tip';
import * as styles from './index.module.scss';
type UserInfo = Pick<User, 'name' | 'username' | 'avatar' | 'primaryEmail' | 'primaryPhone'>;
type Props = {
className?: string;
size?: 'micro' | 'small' | 'medium' | 'large' | 'xlarge';
user?: Partial<Pick<User, 'name' | 'username' | 'avatar' | 'primaryEmail'>>;
user?: Partial<UserInfo>;
hasTooltip?: boolean;
};
function UserAvatar({ className, size = 'medium', user }: Props) {
function UserInfoTipContent({ user }: { user: Partial<UserInfo> }) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { name, primaryEmail, primaryPhone, username } = user;
const itemsToDisplay = [
{ label: t('user_details.field_name'), value: name },
{ label: t('user_details.field_email'), value: primaryEmail },
{
label: t('user_details.field_phone'),
value: conditional(primaryPhone && formatToInternationalPhoneNumber(primaryPhone)),
},
{ label: t('user_details.field_username'), value: username },
];
return (
<>
{itemsToDisplay
.filter(({ value }) => Boolean(value))
.map(({ label, value }) => (
<div key={label} className={styles.row}>
<span className={styles.label}>{label}:</span>
<span className={styles.value}>{value}</span>
</div>
))}
</>
);
}
function UserAvatar({ className, size = 'medium', user, hasTooltip = false }: Props) {
const avatarClassName = classNames(styles.avatar, styles[size]);
const wrapperClassName = classNames(styles.wrapper, styles[size], className);
const defaultColorPalette = [
@ -61,11 +93,16 @@ function UserAvatar({ className, size = 'medium', user }: Props) {
);
return (
<div className={wrapperClassName}>
<div className={avatarClassName} style={{ backgroundColor: color }}>
{nameToDisplay ? nameToDisplay.charAt(0) : <DefaultAvatar />}
<Tooltip
className={styles.tooltip}
content={conditional(hasTooltip && user && <UserInfoTipContent user={user} />)}
>
<div className={wrapperClassName}>
<div className={avatarClassName} style={{ backgroundColor: color }}>
{nameToDisplay ? nameToDisplay.charAt(0) : <DefaultAvatar />}
</div>
</div>
</div>
</Tooltip>
);
}

View file

@ -5,21 +5,18 @@
color: var(--color-text);
.title {
display: inline-block;
display: inline-flex;
align-items: center;
max-width: 100%;
@include _.text-ellipsis;
margin-right: _.unit(1);
}
.id {
display: inline-block;
font: var(--body-small);
color: var(--color-text-secondary);
@include _.text-ellipsis;
span {
margin-left: _.unit(2);
}
}
.link {
display: inline-block;
display: inline-flex;
text-decoration: none;
color: var(--color-text-link);
}

View file

@ -1,10 +1,13 @@
import type { User } from '@logto/schemas';
import { conditionalString } from '@silverhand/essentials';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import useSWR from 'swr';
import type { RequestError } from '@/hooks/use-api';
import { getUserTitle } from '@/utils/user';
import UserAvatar from '../UserAvatar';
import * as styles from './index.module.scss';
@ -15,10 +18,8 @@ type Props = {
function UserName({ userId, isLink = false }: Props) {
const { data, error } = useSWR<User, RequestError>(`api/users/${userId}`);
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const isLoading = !data && !error;
const name = data?.name ?? t('users.unnamed');
const name = conditionalString(data && getUserTitle(data));
if (isLoading) {
return null;
@ -28,12 +29,12 @@ function UserName({ userId, isLink = false }: Props) {
<div className={styles.userName}>
{isLink ? (
<Link to={`/users/${userId}`} className={classNames(styles.title, styles.link)}>
{name}
<UserAvatar hasTooltip size="micro" user={data} />
<span>{name}</span>
</Link>
) : (
<div className={styles.title}>{name}</div>
)}
<div className={styles.id}>{userId}</div>
</div>
);
}

View file

@ -43,7 +43,7 @@ function AuditLogDetails() {
conditional(
userId &&
t('log_details.back_to', {
name: conditional(userData && getUserTitle(userData)) ?? t('users.unnamed'),
name: getUserTitle(userData),
})
) ??
conditional(

View file

@ -98,7 +98,7 @@ function RoleUsers() {
return (
<ItemPreview
title={getUserTitle(user) ?? t('users.unnamed')}
title={getUserTitle(user)}
subtitle={getUserSubtitle(user)}
icon={<UserAvatar size="large" user={user} />}
to={`/users/${id}`}

View file

@ -10,6 +10,7 @@ import ModalLayout from '@/components/ModalLayout';
import UserRolesTransfer from '@/components/UserRolesTransfer';
import useApi from '@/hooks/use-api';
import * as modalStyles from '@/scss/modal.module.scss';
import { getUserTitle } from '@/utils/user';
type Props = {
user: User;
@ -19,7 +20,7 @@ type Props = {
function AssignRolesModal({ user, onClose }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const userName = user.name ?? t('users.unnamed');
const userName = getUserTitle(user);
const [isSubmitting, setIsSubmitting] = useState(false);
const [roles, setRoles] = useState<RoleResponse[]>([]);

View file

@ -114,7 +114,7 @@ function UserDetails() {
<Card className={styles.header}>
<UserAvatar user={data} size="xlarge" />
<div className={styles.metadata}>
<div className={styles.title}>{getUserTitle(data) ?? t('users.unnamed')}</div>
<div className={styles.title}>{getUserTitle(data)}</div>
<div>
{isSuspendedUser && <SuspendedTag />}
{userSubtitle && (

View file

@ -84,7 +84,7 @@ function Users() {
return (
<ItemPreview
title={getUserTitle(user) ?? t('users.unnamed')}
title={getUserTitle(user)}
subtitle={getUserSubtitle(user)}
icon={<UserAvatar size="large" user={user} />}
to={buildDetailsPathname(id)}

View file

@ -1,16 +1,19 @@
import type { User } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { t } from 'i18next';
import { formatToInternationalPhoneNumber } from './phone';
const getUserIdentity = (user: User) => {
const { primaryEmail, primaryPhone, username } = user;
const getSecondaryUserInfo = (user?: User) => {
const { primaryEmail, primaryPhone, username } = user ?? {};
const formattedPhoneNumber = conditional(
primaryPhone && formatToInternationalPhoneNumber(primaryPhone)
);
return primaryEmail ?? formattedPhoneNumber ?? username;
};
export const getUserTitle = (user: User) => user.name ?? getUserIdentity(user);
export const getUserTitle = (user?: User) =>
user?.name ?? getSecondaryUserInfo(user) ?? t('admin_console.users.unnamed');
export const getUserSubtitle = (user: User) => conditional(user.name && getUserIdentity(user));
export const getUserSubtitle = (user?: User) =>
conditional(user?.name && getSecondaryUserInfo(user));