Skip to content

Commit 2e648ea

Browse files
committed
avatar: add new primitives
avatar: add new primitives avatar: improve types ref(avatar) wip test(avatar): rewrite LetterAvatar tests to focus on public API Rewrote tests to focus solely on the component's public behavior: - Initials rendering for various name formats - Fallback to question mark when name is empty or whitespace - Proper handling of multibyte characters, emails, and edge cases Removed tests for internal implementation details like styling and refs. test(avatar): add ImageAvatar tests for public API Added comprehensive tests for ImageAvatar component covering: - Image rendering when src is valid - Fallback to LetterAvatar when src is missing or image fails to load - Proper handling of src changes and error state reset - Props passing to both image and fallback components All tests focus on observable behavior rather than implementation details. test(avatar): enhance Gravatar tests to cover public API Enhanced Gravatar tests to cover: - SHA-256 hashing of gravatarId and URL generation - Remote size and d=404 parameters in URL - Fallback to LetterAvatar for empty/whitespace gravatarId - Props passing to both gravatar image and fallback - Hash regeneration when gravatarId changes - Switching between valid and empty gravatarId All tests focus on observable behavior and public API. fix(avatar): fix ImageAvatar fallback not triggering Fixed issues that prevented the LetterAvatar fallback from displaying when image load fails: 1. Properly destructure `name` from props before spreading to prevent it from being passed to the Image component 2. Remove incorrect `alt` prop being passed from Avatar component to ImageAvatar (ImageAvatarProps explicitly omits 'alt') These changes ensure the onError handler from mergeProps is properly attached without prop conflicts, allowing the error state to trigger and display the LetterAvatar fallback when an image fails to load. fix avatar correct usage correct usage correct usage use fixtures revert entire html interface
1 parent 259af5e commit 2e648ea

31 files changed

+1221
-558
lines changed

static/app/components/avatarChooser/index.tsx

Lines changed: 33 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import {useState} from 'react';
22
import styled from '@emotion/styled';
33

44
import {OrganizationAvatar, SentryAppAvatar, UserAvatar} from '@sentry/scraps/avatar';
5-
import type {BaseAvatarProps} from '@sentry/scraps/avatar';
5+
import type {AvatarProps} from '@sentry/scraps/avatar';
66
import {Button, LinkButton} from '@sentry/scraps/button';
77
import {Flex, Stack} from '@sentry/scraps/layout';
88
import {ExternalLink} from '@sentry/scraps/link';
@@ -14,7 +14,7 @@ import {Hovercard} from 'sentry/components/hovercard';
1414
import Panel from 'sentry/components/panels/panel';
1515
import PanelFooter from 'sentry/components/panels/panelFooter';
1616
import PanelHeader from 'sentry/components/panels/panelHeader';
17-
import {IconImage, IconOpen, IconUpload} from 'sentry/icons';
17+
import {IconOpen, IconUpload} from 'sentry/icons';
1818
import {t, tct} from 'sentry/locale';
1919
import {space} from 'sentry/styles/space';
2020
import type {Avatar} from 'sentry/types/core';
@@ -238,28 +238,27 @@ function AvatarChooser({
238238
</AvatarActions>
239239
);
240240

241-
const emptyGravatar = (
242-
<BlankAvatar>
243-
<IconImage size="xl" />
244-
</BlankAvatar>
245-
);
246-
247-
const emptyUploader = (
248-
<Flex justify="center" align="center" height="100%">
249-
<Button size="xs" icon={<IconUpload />} onClick={openUpload}>
250-
{t('Upload')}
251-
</Button>
252-
</Flex>
253-
);
254-
255-
const backupAvatars: Partial<Record<AvatarType, React.ReactNode>> = {
256-
gravatar: emptyGravatar,
257-
upload: emptyUploader,
258-
};
259-
260-
const sharedAvatarProps: Partial<Omit<BaseAvatarProps, 'ref'>> = {
261-
type: avatarType,
262-
backupAvatar: backupAvatars[avatarType],
241+
// const emptyGravatar = (
242+
// <BlankAvatar>
243+
// <IconImage size="xl" />
244+
// </BlankAvatar>
245+
// );
246+
247+
// const emptyUploader = (
248+
// <Flex justify="center" align="center" height="100%">
249+
// <Button size="xs" icon={<IconUpload />} onClick={openUpload}>
250+
// {t('Upload')}
251+
// </Button>
252+
// </Flex>
253+
// );
254+
255+
// const backupAvatars: Partial<Record<AvatarType, React.ReactNode>> = {
256+
// gravatar: emptyGravatar,
257+
// upload: emptyUploader,
258+
// };
259+
260+
const sharedAvatarProps: Partial<Omit<AvatarProps, 'ref'>> = {
261+
// backupAvatar: backupAvatars[avatarType],
263262
size: 90,
264263
};
265264

@@ -416,16 +415,16 @@ const AvatarHelp = styled('p')`
416415
width: 50%;
417416
`;
418417

419-
const BlankAvatar = styled('div')`
420-
border-radius: 50%;
421-
display: flex;
422-
align-items: center;
423-
justify-content: center;
424-
color: ${p => p.theme.colors.gray200};
425-
background: ${p => p.theme.tokens.background.secondary};
426-
height: 90px;
427-
width: 90px;
428-
`;
418+
// const BlankAvatar = styled('div')`
419+
// border-radius: 50%;
420+
// display: flex;
421+
// align-items: center;
422+
// justify-content: center;
423+
// color: ${p => p.theme.colors.gray200};
424+
// background: ${p => p.theme.tokens.background.secondary};
425+
// height: 90px;
426+
// width: 90px;
427+
// `;
429428

430429
const AvatarActions = styled('div')`
431430
position: absolute;

static/app/components/commitRow.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,11 @@ function CommitRow({
9393
<Message>{formatCommitMessage(commit.message)}</Message>
9494
)}
9595
<MetaWrapper>
96-
{customAvatar ? customAvatar : <UserAvatar size={16} user={commit.author} />}
96+
{customAvatar ? (
97+
customAvatar
98+
) : commit.author ? (
99+
<UserAvatar size={16} user={commit.author} />
100+
) : null}
97101
<Meta hasStreamlinedUI>
98102
<Tooltip
99103
title={tct(
@@ -193,16 +197,14 @@ function CommitRow({
193197
</EmailWarning>
194198
}
195199
>
196-
<UserAvatar size={36} user={commit.author} />
200+
{commit.author ? <UserAvatar size={36} user={commit.author} /> : null}
197201
<EmailWarningIcon data-test-id="email-warning">
198202
<IconWarning size="xs" />
199203
</EmailWarningIcon>
200204
</Hovercard>
201205
</AvatarWrapper>
202206
) : (
203-
<div>
204-
<UserAvatar size={36} user={commit.author} />
205-
</div>
207+
<div>{commit.author ? <UserAvatar size={36} user={commit.author} /> : null}</div>
206208
)}
207209

208210
<CommitMessage>

static/app/components/core/avatar/actorAvatar.tsx

Lines changed: 19 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import type React from 'react';
21
import {useMemo} from 'react';
32
import * as Sentry from '@sentry/react';
43

@@ -7,7 +6,7 @@ import type {Actor} from 'sentry/types/core';
76
import {useMembers} from 'sentry/utils/useMembers';
87
import {useTeamsById} from 'sentry/utils/useTeamsById';
98

10-
import {BaseAvatar, type BaseAvatarProps} from './baseAvatar/baseAvatar';
9+
import {Avatar, type AvatarProps} from './avatar';
1110
import {TeamAvatar, type TeamAvatarProps} from './teamAvatar';
1211
import {UserAvatar, type UserAvatarProps} from './userAvatar';
1312

@@ -16,13 +15,11 @@ interface SimpleActor extends Omit<Actor, 'name'> {
1615
name?: string;
1716
}
1817

19-
export interface ActorAvatarProps extends BaseAvatarProps {
18+
export interface ActorAvatarProps extends Omit<AvatarProps, 'round'> {
2019
actor: SimpleActor;
21-
ref?: React.Ref<HTMLSpanElement | SVGSVGElement | HTMLImageElement>;
2220
}
2321

2422
export function ActorAvatar({
25-
ref,
2623
size = 24,
2724
hasTooltip = true,
2825
actor,
@@ -35,11 +32,11 @@ export function ActorAvatar({
3532
};
3633

3734
if (actor.type === 'user') {
38-
return <AsyncMemberAvatar userActor={actor} {...otherProps} ref={ref} />;
35+
return <AsyncMemberAvatar actor={actor} {...otherProps} />;
3936
}
4037

4138
if (actor.type === 'team') {
42-
return <AsyncTeamAvatar teamId={actor.id} {...otherProps} ref={ref} />;
39+
return <AsyncTeamAvatar teamId={actor.id} {...otherProps} />;
4340
}
4441

4542
Sentry.withScope(scope => {
@@ -56,16 +53,18 @@ export function ActorAvatar({
5653

5754
interface AsyncTeamAvatarProps extends Omit<TeamAvatarProps, 'team'> {
5855
teamId: string;
59-
ref?: React.Ref<HTMLSpanElement | SVGSVGElement | HTMLImageElement>;
6056
}
6157

6258
function AsyncTeamAvatar({ref, teamId, ...props}: AsyncTeamAvatarProps) {
6359
const {teams, isLoading} = useTeamsById({ids: [teamId]});
6460
const team = teams.find(t => t.id === teamId);
6561

6662
if (isLoading) {
67-
const size = `${props.size}px`;
68-
return <Placeholder width={size} height={size} />;
63+
return <Placeholder width={`${props.size}px`} height={`${props.size}px`} />;
64+
}
65+
66+
if (!team) {
67+
return <Avatar type="letter_avatar" name={teamId} identifier={teamId} />;
6968
}
7069

7170
return <TeamAvatar team={team} {...props} ref={ref} />;
@@ -74,15 +73,14 @@ function AsyncTeamAvatar({ref, teamId, ...props}: AsyncTeamAvatarProps) {
7473
/**
7574
* Wrapper to assist loading the user from api or store
7675
*/
77-
interface AsyncMemberAvatarProps extends Omit<UserAvatarProps, 'user'> {
78-
userActor: SimpleActor;
79-
ref?: React.Ref<HTMLSpanElement | SVGSVGElement | HTMLImageElement>;
76+
interface AsyncMemberAvatarProps extends Omit<UserAvatarProps, 'user' | 'round'> {
77+
actor: SimpleActor;
8078
}
8179

82-
function AsyncMemberAvatar({ref, userActor, ...props}: AsyncMemberAvatarProps) {
83-
const ids = useMemo(() => [userActor.id], [userActor.id]);
80+
function AsyncMemberAvatar({ref, actor, ...props}: AsyncMemberAvatarProps) {
81+
const ids = useMemo(() => [actor.id], [actor.id]);
8482
const {members, fetching} = useMembers({ids});
85-
const member = members.find(u => u.id === userActor.id);
83+
const member = members.find(u => u.id === actor.id);
8684

8785
if (fetching) {
8886
const size = `${props.size}px`;
@@ -91,10 +89,11 @@ function AsyncMemberAvatar({ref, userActor, ...props}: AsyncMemberAvatarProps) {
9189

9290
if (!member) {
9391
return (
94-
<BaseAvatar
95-
ref={ref}
96-
size={props.size}
97-
title={userActor.name ?? userActor.email}
92+
<Avatar
93+
{...props}
94+
type="letter_avatar"
95+
name={actor.name ?? actor.email ?? actor.id}
96+
identifier={actor.id}
9897
round
9998
/>
10099
);

static/app/components/core/avatar/avatar.mdx

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -130,17 +130,6 @@ There are multiple avatar components which represent users, teams, organizations
130130
<DocIntegrationAvatar size={PREVIEW_SIZE} docIntegration={DOC_INTEGRATION} />
131131
</Storybook.Demo>
132132

133-
## Props
134-
135-
All avatar components accept common props, to customize `size` and render tooltips with `hasTooltip`, `tooltip`, and `tooltipOptions`.
136-
137-
<Storybook.Demo>
138-
<UserAvatar user={USER} size={64} hasTooltip tooltip="This avatar has a tooltip" />
139-
</Storybook.Demo>
140-
```jsx
141-
<UserAvatar user={user} size={64} hasTooltip tooltip="This avatar has a tooltip" />
142-
```
143-
144133
## Types
145134

146135
To distinguish between individuals (users) and groups (teams, organizations, etc) at glance, avatars may use different shapes. Individuals are displayed with a round avatar, but groups are displayed with a square avatar.
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import type React from 'react';
2+
import styled from '@emotion/styled';
3+
import classNames from 'classnames';
4+
5+
import {Tooltip, type TooltipProps} from '@sentry/scraps/tooltip';
6+
7+
import {Gravatar} from './gravatar/gravatar';
8+
import {ImageAvatar} from './imageAvatar/imageAvatar';
9+
import {LetterAvatar} from './letterAvatar/letterAvatar';
10+
import type {BaseAvatarStyleProps} from './avatarComponentStyles';
11+
12+
export interface AvatarProps extends BaseAvatarStyleProps {
13+
className?: string;
14+
hasTooltip?: boolean;
15+
ref?: React.Ref<HTMLSpanElement>;
16+
style?: React.CSSProperties;
17+
title?: string;
18+
tooltip?: React.ReactNode;
19+
tooltipOptions?: Omit<TooltipProps, 'children' | 'title'>;
20+
}
21+
22+
export interface GravatarBaseAvatarProps extends AvatarProps {
23+
gravatarId: string;
24+
name: string;
25+
type: 'gravatar';
26+
}
27+
28+
export interface LetterBaseAvatarProps extends AvatarProps {
29+
identifier: string;
30+
name: string;
31+
type: 'letter_avatar';
32+
}
33+
34+
export interface UploadBaseAvatarProps extends AvatarProps {
35+
identifier: string;
36+
name: string;
37+
type: 'upload';
38+
uploadUrl: string;
39+
}
40+
41+
export function Avatar({
42+
ref,
43+
className,
44+
size,
45+
style,
46+
tooltip,
47+
tooltipOptions,
48+
hasTooltip = false,
49+
...props
50+
}: GravatarBaseAvatarProps | LetterBaseAvatarProps | UploadBaseAvatarProps) {
51+
return (
52+
<Tooltip title={tooltip} disabled={!hasTooltip} {...tooltipOptions} skipWrapper>
53+
<AvatarContainer
54+
ref={ref as React.Ref<HTMLSpanElement>}
55+
data-test-id={`${props.type}-avatar`}
56+
className={classNames('avatar', className)}
57+
suggested={!!props.suggested}
58+
style={{...(size ? {height: size, width: size} : {}), ...style}}
59+
{...props}
60+
>
61+
{props.type === 'upload' ? (
62+
<ImageAvatar src={props.uploadUrl} {...props} />
63+
) : props.type === 'gravatar' ? (
64+
<Gravatar {...props} />
65+
) : props.type === 'letter_avatar' ? (
66+
<LetterAvatar {...(props as LetterBaseAvatarProps)} />
67+
) : null}
68+
</AvatarContainer>
69+
</Tooltip>
70+
);
71+
}
72+
73+
// Note: Avatar will not always be a child of a flex layout, but this seems like a
74+
// sensible default.
75+
const AvatarContainer = styled('span')<BaseAvatarStyleProps>`
76+
flex-shrink: 0;
77+
border-radius: ${p => (p.round ? '50%' : '3px')};
78+
border: ${p =>
79+
p.suggested ? `1px dashed ${p.theme.tokens.border.neutral.vibrant}` : 'none'};
80+
background-color: ${p => (p.suggested ? p.theme.tokens.background.primary : 'none')};
81+
`;

static/app/components/core/avatar/baseAvatar/baseAvatarComponentStyles.tsx renamed to static/app/components/core/avatar/avatarComponentStyles.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import {css} from '@emotion/react';
22

3+
/** Defines the styling interface for all avatar components */
34
export interface BaseAvatarStyleProps {
45
round?: boolean;
6+
size?: number;
57
suggested?: boolean;
68
}
79

0 commit comments

Comments
 (0)