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 { CommentableEntityForSelect } from '@/comments/types/CommentableEntityForSelect';
import CompanyChip from '@/companies/components/CompanyChip';
import { CompanyChip } from '@/companies/components/CompanyChip';
import { useScopedHotkeys } from '@/hotkeys/hooks/useScopedHotkeys';
import { InternalHotkeysScope } from '@/hotkeys/types/internal/InternalHotkeysScope';
import { PersonChip } from '@/people/components/PersonChip';

View File

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

View File

@ -16,7 +16,10 @@ export function CompanyAccountOwnerCell({ company }: OwnProps) {
editModeContent={<CompanyAccountOwnerPicker company={company} />}
nonEditModeContent={
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';
export type CompanyChipPropsType = {
id?: string;
id: string;
name: string;
picture?: string;
};
@ -50,7 +50,7 @@ const StyledContainerNoLink = styled.div`
${baseStyle}
`;
function CompanyChip({ id, name, picture }: CompanyChipPropsType) {
export function CompanyChip({ id, name, picture }: CompanyChipPropsType) {
const ContainerComponent = id ? StyledContainerLink : StyledContainerNoLink;
return (
@ -58,6 +58,7 @@ function CompanyChip({ id, name, picture }: CompanyChipPropsType) {
{picture && (
<Avatar
avatarUrl={picture?.toString()}
colorId={id}
placeholder={name}
type="squared"
size={14}
@ -67,5 +68,3 @@ function CompanyChip({ id, name, picture }: CompanyChipPropsType) {
</ContainerComponent>
);
}
export default CompanyChip;

View File

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

View File

@ -1,9 +1,10 @@
import { BrowserRouter } from 'react-router-dom';
import styled from '@emotion/styled';
import type { Meta, StoryObj } from '@storybook/react';
import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
import CompanyChip from '../CompanyChip';
import { CompanyChip } from '../CompanyChip';
const meta: Meta<typeof CompanyChip> = {
title: 'Modules/Companies/CompanyChip',
@ -32,10 +33,13 @@ const TestCellContainer = styled.div`
export const SmallName: Story = {
render: getRenderWrapperForComponent(
<TestCellContainer>
<CompanyChip
name="Airbnb"
picture="https://api.faviconkit.com/airbnb.com/144"
/>
<BrowserRouter>
<CompanyChip
id="airbnb"
name="Airbnb"
picture="https://api.faviconkit.com/airbnb.com/144"
/>
</BrowserRouter>
</TestCellContainer>,
),
};
@ -43,10 +47,13 @@ export const SmallName: Story = {
export const BigName: Story = {
render: getRenderWrapperForComponent(
<TestCellContainer>
<CompanyChip
name="Google with a real big name to overflow the cell"
picture="https://api.faviconkit.com/google.com/144"
/>
<BrowserRouter>
<CompanyChip
id="google"
name="Google with a real big name to overflow the cell"
picture="https://api.faviconkit.com/google.com/144"
/>
</BrowserRouter>
</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 { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState';
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 styled from '@emotion/styled';
import PersonPlaceholder from './person-placeholder.png';
import { Avatar } from '@/users/components/Avatar';
export type PersonChipPropsType = {
id?: string;
id: string;
name: string;
picture?: string;
};
@ -52,10 +52,12 @@ export function PersonChip({ id, name, picture }: PersonChipPropsType) {
const ContainerComponent = id ? StyledContainerLink : StyledContainerNoLink;
return (
<ContainerComponent data-testid="person-chip" to={`/person/${id}`}>
<img
data-testid="person-chip-image"
src={picture ? picture.toString() : PersonPlaceholder.toString()}
alt="person"
<Avatar
avatarUrl={picture}
colorId={id}
placeholder={name}
size={14}
type="rounded"
/>
<StyledName>{name}</StyledName>
</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
avatarUrl={entity.avatarUrl}
colorId={entity.id}
placeholder={entity.name}
size={16}
type={entity.avatarType ?? 'rounded'}

View File

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

View File

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

View File

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