feat: colored avatar (#554)

* feat: colored avatar

* fix: use id instead of name & remove unused

* fix: remove unused

* Allow empty ID to avoid empty string

* Fix tests

* Add person chip story

---------

Co-authored-by: Emilien <emilien.chauvet.enpc@gmail.com>
This commit is contained in:
Jérémy M
2023-07-10 20:24:09 +02:00
committed by GitHub
parent c9292365c0
commit 3079747c83
18 changed files with 129 additions and 47 deletions

View File

@ -10,7 +10,7 @@ import {
import { useHandleCheckableCommentThreadTargetChange } from '@/comments/hooks/useHandleCheckableCommentThreadTargetChange'; import { useHandleCheckableCommentThreadTargetChange } from '@/comments/hooks/useHandleCheckableCommentThreadTargetChange';
import { CommentableEntityForSelect } from '@/comments/types/CommentableEntityForSelect'; import { CommentableEntityForSelect } from '@/comments/types/CommentableEntityForSelect';
import CompanyChip from '@/companies/components/CompanyChip'; import { CompanyChip } from '@/companies/components/CompanyChip';
import { useScopedHotkeys } from '@/hotkeys/hooks/useScopedHotkeys'; import { useScopedHotkeys } from '@/hotkeys/hooks/useScopedHotkeys';
import { InternalHotkeysScope } from '@/hotkeys/types/internal/InternalHotkeysScope'; import { InternalHotkeysScope } from '@/hotkeys/types/internal/InternalHotkeysScope';
import { PersonChip } from '@/people/components/PersonChip'; import { PersonChip } from '@/people/components/PersonChip';

View File

@ -8,7 +8,6 @@ import {
beautifyExactDate, beautifyExactDate,
beautifyPastDateRelativeToNow, beautifyPastDateRelativeToNow,
} from '@/utils/datetime/date-utils'; } from '@/utils/datetime/date-utils';
import { isNonEmptyString } from '@/utils/type-guards/isNonEmptyString';
type OwnProps = { type OwnProps = {
comment: Pick<CommentForDrawer, 'id' | 'author' | 'createdAt'>; comment: Pick<CommentForDrawer, 'id' | 'author' | 'createdAt'>;
@ -74,17 +73,14 @@ export function CommentHeader({ comment, actionBar }: OwnProps) {
const avatarUrl = author.avatarUrl; const avatarUrl = author.avatarUrl;
const commentId = comment.id; const commentId = comment.id;
const capitalizedFirstUsernameLetter = isNonEmptyString(authorName)
? authorName.toLocaleUpperCase()[0]
: '';
return ( return (
<StyledContainer> <StyledContainer>
<StyledLeftContainer> <StyledLeftContainer>
<Avatar <Avatar
avatarUrl={avatarUrl} avatarUrl={avatarUrl}
size={theme.icon.size.md} size={theme.icon.size.md}
placeholder={capitalizedFirstUsernameLetter} colorId={author.id}
placeholder={author.displayName}
/> />
<StyledName>{authorName}</StyledName> <StyledName>{authorName}</StyledName>
{showDate && ( {showDate && (

View File

@ -16,7 +16,10 @@ export function CompanyAccountOwnerCell({ company }: OwnProps) {
editModeContent={<CompanyAccountOwnerPicker company={company} />} editModeContent={<CompanyAccountOwnerPicker company={company} />}
nonEditModeContent={ nonEditModeContent={
company.accountOwner?.displayName ? ( company.accountOwner?.displayName ? (
<PersonChip name={company.accountOwner?.displayName ?? ''} /> <PersonChip
id={company.accountOwner.id}
name={company.accountOwner?.displayName ?? ''}
/>
) : ( ) : (
<></> <></>
) )

View File

@ -5,7 +5,7 @@ import styled from '@emotion/styled';
import { Avatar } from '@/users/components/Avatar'; import { Avatar } from '@/users/components/Avatar';
export type CompanyChipPropsType = { export type CompanyChipPropsType = {
id?: string; id: string;
name: string; name: string;
picture?: string; picture?: string;
}; };
@ -50,7 +50,7 @@ const StyledContainerNoLink = styled.div`
${baseStyle} ${baseStyle}
`; `;
function CompanyChip({ id, name, picture }: CompanyChipPropsType) { export function CompanyChip({ id, name, picture }: CompanyChipPropsType) {
const ContainerComponent = id ? StyledContainerLink : StyledContainerNoLink; const ContainerComponent = id ? StyledContainerLink : StyledContainerNoLink;
return ( return (
@ -58,6 +58,7 @@ function CompanyChip({ id, name, picture }: CompanyChipPropsType) {
{picture && ( {picture && (
<Avatar <Avatar
avatarUrl={picture?.toString()} avatarUrl={picture?.toString()}
colorId={id}
placeholder={name} placeholder={name}
type="squared" type="squared"
size={14} size={14}
@ -67,5 +68,3 @@ function CompanyChip({ id, name, picture }: CompanyChipPropsType) {
</ContainerComponent> </ContainerComponent>
); );
} }
export default CompanyChip;

View File

@ -8,7 +8,7 @@ import {
useUpdateCompanyMutation, useUpdateCompanyMutation,
} from '~/generated/graphql'; } from '~/generated/graphql';
import CompanyChip from './CompanyChip'; import { CompanyChip } from './CompanyChip';
type OwnProps = { type OwnProps = {
company: Pick< company: Pick<

View File

@ -1,9 +1,10 @@
import { BrowserRouter } from 'react-router-dom';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { getRenderWrapperForComponent } from '~/testing/renderWrappers'; import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
import CompanyChip from '../CompanyChip'; import { CompanyChip } from '../CompanyChip';
const meta: Meta<typeof CompanyChip> = { const meta: Meta<typeof CompanyChip> = {
title: 'Modules/Companies/CompanyChip', title: 'Modules/Companies/CompanyChip',
@ -32,10 +33,13 @@ const TestCellContainer = styled.div`
export const SmallName: Story = { export const SmallName: Story = {
render: getRenderWrapperForComponent( render: getRenderWrapperForComponent(
<TestCellContainer> <TestCellContainer>
<CompanyChip <BrowserRouter>
name="Airbnb" <CompanyChip
picture="https://api.faviconkit.com/airbnb.com/144" id="airbnb"
/> name="Airbnb"
picture="https://api.faviconkit.com/airbnb.com/144"
/>
</BrowserRouter>
</TestCellContainer>, </TestCellContainer>,
), ),
}; };
@ -43,10 +47,13 @@ export const SmallName: Story = {
export const BigName: Story = { export const BigName: Story = {
render: getRenderWrapperForComponent( render: getRenderWrapperForComponent(
<TestCellContainer> <TestCellContainer>
<CompanyChip <BrowserRouter>
name="Google with a real big name to overflow the cell" <CompanyChip
picture="https://api.faviconkit.com/google.com/144" id="google"
/> name="Google with a real big name to overflow the cell"
picture="https://api.faviconkit.com/google.com/144"
/>
</BrowserRouter>
</TestCellContainer>, </TestCellContainer>,
), ),
}; };

View File

@ -1,4 +1,4 @@
import CompanyChip from '@/companies/components/CompanyChip'; import { CompanyChip } from '@/companies/components/CompanyChip';
import { InternalHotkeysScope } from '@/hotkeys/types/internal/InternalHotkeysScope'; import { InternalHotkeysScope } from '@/hotkeys/types/internal/InternalHotkeysScope';
import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState'; import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState';
import { EditableCell } from '@/ui/components/editable-cell/EditableCell'; import { EditableCell } from '@/ui/components/editable-cell/EditableCell';

View File

@ -3,10 +3,10 @@ import { Link } from 'react-router-dom';
import { Theme } from '@emotion/react'; import { Theme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import PersonPlaceholder from './person-placeholder.png'; import { Avatar } from '@/users/components/Avatar';
export type PersonChipPropsType = { export type PersonChipPropsType = {
id?: string; id: string;
name: string; name: string;
picture?: string; picture?: string;
}; };
@ -52,10 +52,12 @@ export function PersonChip({ id, name, picture }: PersonChipPropsType) {
const ContainerComponent = id ? StyledContainerLink : StyledContainerNoLink; const ContainerComponent = id ? StyledContainerLink : StyledContainerNoLink;
return ( return (
<ContainerComponent data-testid="person-chip" to={`/person/${id}`}> <ContainerComponent data-testid="person-chip" to={`/person/${id}`}>
<img <Avatar
data-testid="person-chip-image" avatarUrl={picture}
src={picture ? picture.toString() : PersonPlaceholder.toString()} colorId={id}
alt="person" placeholder={name}
size={14}
type="rounded"
/> />
<StyledName>{name}</StyledName> <StyledName>{name}</StyledName>
</ContainerComponent> </ContainerComponent>

View File

@ -0,0 +1,47 @@
import { BrowserRouter } from 'react-router-dom';
import styled from '@emotion/styled';
import type { Meta, StoryObj } from '@storybook/react';
import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
import { PersonChip } from '../PersonChip';
const meta: Meta<typeof PersonChip> = {
title: 'Modules/Companies/PersonChip',
component: PersonChip,
};
export default meta;
type Story = StoryObj<typeof PersonChip>;
const TestCellContainer = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.primary};
display: flex;
height: fit-content;
justify-content: space-between;
max-width: 250px;
min-width: 250px;
overflow: hidden;
text-wrap: nowrap;
`;
export const SmallName: Story = {
render: getRenderWrapperForComponent(
<TestCellContainer>
<BrowserRouter>
<PersonChip id="tim_fake_id" name="Tim C." />
</BrowserRouter>
</TestCellContainer>,
),
};
export const BigName: Story = {
render: getRenderWrapperForComponent(
<TestCellContainer>
<BrowserRouter>
<PersonChip id="steve_fake_id" name="Steve J." />
</BrowserRouter>
</TestCellContainer>,
),
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@ -71,6 +71,7 @@ export function MultipleEntitySelect<
> >
<Avatar <Avatar
avatarUrl={entity.avatarUrl} avatarUrl={entity.avatarUrl}
colorId={entity.id}
placeholder={entity.name} placeholder={entity.name}
size={16} size={16}
type={entity.avatarType ?? 'rounded'} type={entity.avatarType ?? 'rounded'}

View File

@ -69,6 +69,7 @@ export function SingleEntitySelectBase<
> >
<Avatar <Avatar
avatarUrl={entity.avatarUrl} avatarUrl={entity.avatarUrl}
colorId={entity.id}
placeholder={entity.name} placeholder={entity.name}
size={16} size={16}
type={entity.avatarType ?? 'rounded'} type={entity.avatarType ?? 'rounded'}

View File

@ -9,6 +9,13 @@ import {
beautifyPastDateRelativeToNow, beautifyPastDateRelativeToNow,
} from '@/utils/datetime/date-utils'; } from '@/utils/datetime/date-utils';
type OwnProps = {
id?: string;
logoOrAvatar?: string;
title: string;
date: string;
};
const StyledShowPageSummaryCard = styled.div` const StyledShowPageSummaryCard = styled.div`
align-items: center; align-items: center;
display: flex; display: flex;
@ -46,14 +53,11 @@ const StyledTooltip = styled(Tooltip)`
`; `;
export function ShowPageSummaryCard({ export function ShowPageSummaryCard({
id,
logoOrAvatar, logoOrAvatar,
title, title,
date, date,
}: { }: OwnProps) {
logoOrAvatar?: string;
title: string;
date: string;
}) {
const beautifiedCreatedAt = const beautifiedCreatedAt =
date !== '' ? beautifyPastDateRelativeToNow(date) : ''; date !== '' ? beautifyPastDateRelativeToNow(date) : '';
const exactCreatedAt = date !== '' ? beautifyExactDate(date) : ''; const exactCreatedAt = date !== '' ? beautifyExactDate(date) : '';
@ -65,6 +69,7 @@ export function ShowPageSummaryCard({
<Avatar <Avatar
avatarUrl={logoOrAvatar} avatarUrl={logoOrAvatar}
size={theme.icon.size.xl} size={theme.icon.size.xl}
colorId={id}
placeholder={title} placeholder={title}
/> />
<StyledInfoContainer> <StyledInfoContainer>

View File

@ -1,5 +1,6 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { stringToHslColor } from '@/utils/string-to-hsl';
import { isNonEmptyString } from '@/utils/type-guards/isNonEmptyString'; import { isNonEmptyString } from '@/utils/type-guards/isNonEmptyString';
export type AvatarType = 'squared' | 'rounded'; export type AvatarType = 'squared' | 'rounded';
@ -8,26 +9,23 @@ type OwnProps = {
avatarUrl: string | null | undefined; avatarUrl: string | null | undefined;
size: number; size: number;
placeholder: string; placeholder: string;
colorId?: string;
type?: AvatarType; type?: AvatarType;
}; };
export const StyledAvatar = styled.div<Omit<OwnProps, 'placeholder'>>` export const StyledAvatar = styled.div<OwnProps & { colorId: string }>`
align-items: center; align-items: center;
background-color: ${(props) => background-color: ${({ avatarUrl, colorId }) =>
!isNonEmptyString(props.avatarUrl) !isNonEmptyString(avatarUrl) ? stringToHslColor(colorId, 75, 85) : 'none'};
? props.theme.background.tertiary ${({ avatarUrl }) =>
: 'none'}; isNonEmptyString(avatarUrl) ? `background-image: url(${avatarUrl});` : ''}
${(props) =>
isNonEmptyString(props.avatarUrl)
? `background-image: url(${props.avatarUrl});`
: ''}
background-size: cover; background-size: cover;
border-radius: ${(props) => (props.type === 'rounded' ? '50%' : '2px')}; border-radius: ${(props) => (props.type === 'rounded' ? '50%' : '2px')};
color: ${({ theme }) => theme.font.color.primary}; color: ${({ colorId }) => stringToHslColor(colorId, 75, 25)};
display: flex; display: flex;
flex-shrink: 0; flex-shrink: 0;
font-size: ${({ theme }) => theme.font.size.sm}; font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.medium}; font-weight: ${({ theme }) => theme.font.weight.medium};
height: ${(props) => props.size}px; height: ${(props) => props.size}px;
@ -39,12 +37,19 @@ export function Avatar({
avatarUrl, avatarUrl,
size, size,
placeholder, placeholder,
colorId = placeholder,
type = 'squared', type = 'squared',
}: OwnProps) { }: OwnProps) {
const noAvatarUrl = !isNonEmptyString(avatarUrl); const noAvatarUrl = !isNonEmptyString(avatarUrl);
return ( return (
<StyledAvatar avatarUrl={avatarUrl} size={size} type={type}> <StyledAvatar
avatarUrl={avatarUrl}
placeholder={placeholder}
size={size}
type={type}
colorId={colorId}
>
{noAvatarUrl && placeholder[0]?.toLocaleUpperCase()} {noAvatarUrl && placeholder[0]?.toLocaleUpperCase()}
</StyledAvatar> </StyledAvatar>
); );

View File

@ -0,0 +1,13 @@
export function stringToHslColor(
str: string,
saturation: number,
lightness: number,
) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
const h = hash % 360;
return 'hsl(' + h + ', ' + saturation + '%, ' + lightness + '%)';
}

View File

@ -33,7 +33,7 @@ const EmailText = styled.span`
type OwnProps = { type OwnProps = {
workspaceMember: { workspaceMember: {
user: Pick<User, 'firstName' | 'lastName' | 'avatarUrl' | 'email'>; user: Pick<User, 'id' | 'firstName' | 'lastName' | 'avatarUrl' | 'email'>;
}; };
accessory?: React.ReactNode; accessory?: React.ReactNode;
}; };
@ -43,6 +43,7 @@ export function WorkspaceMemberCard({ workspaceMember, accessory }: OwnProps) {
<StyledContainer> <StyledContainer>
<Avatar <Avatar
avatarUrl={getImageAbsoluteURIOrBase64(workspaceMember.user.avatarUrl)} avatarUrl={getImageAbsoluteURIOrBase64(workspaceMember.user.avatarUrl)}
colorId={workspaceMember.user.id}
placeholder={workspaceMember.user.firstName || ''} placeholder={workspaceMember.user.firstName || ''}
type="squared" type="squared"
size={40} size={40}

View File

@ -37,6 +37,7 @@ export function CompanyShow() {
<> <>
<ShowPageLeftContainer> <ShowPageLeftContainer>
<ShowPageSummaryCard <ShowPageSummaryCard
id={company?.id}
logoOrAvatar={getLogoUrlFromDomainName(company?.domainName ?? '')} logoOrAvatar={getLogoUrlFromDomainName(company?.domainName ?? '')}
title={company?.name ?? 'No name'} title={company?.name ?? 'No name'}
date={company?.createdAt ?? ''} date={company?.createdAt ?? ''}

View File

@ -28,6 +28,7 @@ export function PersonShow() {
<> <>
<ShowPageLeftContainer> <ShowPageLeftContainer>
<ShowPageSummaryCard <ShowPageSummaryCard
id={person?.id}
title={person?.displayName ?? 'No name'} title={person?.displayName ?? 'No name'}
date={person?.createdAt ?? ''} date={person?.createdAt ?? ''}
/> />