Skip to content

ref(scraps) refactor and test avatar component#107799

Draft
JonasBa wants to merge 10 commits intomasterfrom
jb/avatar/refactor
Draft

ref(scraps) refactor and test avatar component#107799
JonasBa wants to merge 10 commits intomasterfrom
jb/avatar/refactor

Conversation

@JonasBa
Copy link
Member

@JonasBa JonasBa commented Feb 6, 2026

Refactor avatar components to modular architecture

The old BaseAvatar component handled all avatar types in one place with a complex props interface that couldn't catch invalid prop combinations. The fallback logic propagated child updates back to parents through callbacks, making the flow indirect and hard to follow.

This splits it into dedicated LetterAvatar, Gravatar, and ImageAvatar components with a type-discriminated Avatar entry point. The new API is explicit: both ImageAvatar and Gravatar directly render LetterAvatar when images fail to load, no callback propagation needed. TypeScript discriminated unions prevent invalid prop combinations.

The refactor makes it much clearer that UserAvatar, TeamAvatar, and OrganizationAvatar are just thin wrappers that extract avatar data from their respective Sentry entities and pass it to the base Avatar component. Includes comprehensive test coverage and fixes 2 test failures relying on removed title attributes.

@github-actions github-actions bot added the Scope: Frontend Automatically applied to PRs that change frontend components label Feb 6, 2026
@JonasBa JonasBa changed the title ref(scraps) move baseavatar, gravatar and letteravatar to separate mo… ref(scraps) refactor and test avatar component Feb 6, 2026
customAvatar
) : commit.author ? (
<UserAvatar size={16} user={commit.author} />
) : null}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

null rendering was previously implicit when user was falsy

}
>
<UserAvatar size={36} user={commit.author} />
{commit.author ? <UserAvatar size={36} user={commit.author} /> : null}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

null rendering was previously implicit when user was falsy

<div>
<UserAvatar size={36} user={commit.author} />
</div>
<div>{commit.author ? <UserAvatar size={36} user={commit.author} /> : null}</div>
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

null rendering was previously implicit when user was falsy

@@ -64,7 +64,7 @@ export function ReleaseCommit({commit}: ReleaseCommitProps) {
<CommitContent>
<Message>{formatCommitMessage(commit.message)}</Message>
<MetaWrapper>
<UserAvatar size={16} user={commit.author} />
{commit.author ? <UserAvatar size={16} user={commit.author} /> : null}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

null rendering was previously implicit when user was falsy

@@ -99,7 +99,7 @@ function CommitAuthorBreakdown({orgId, projectSlug, version}: Props) {
>
{sortedAuthorsByNumberOfCommits.map(({commitCount, author}, index) => (
<AuthorLine key={author?.email ?? index}>
<UserAvatar user={author} size={20} hasTooltip />
{author ? <UserAvatar user={author} size={20} hasTooltip /> : null}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

null rendering was previously implicit when user was falsy

const Avatar = styled(ActorAvatar)<{index: number; reverse: boolean}>`
${translateStyles}
transform: translateX(${p => (p.reverse ? 60 * p.index : 60 * -p.index)}%);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unnecessary wrapper

<UserAvatar
user={user}
size={parseInt(iconSize, 10)}
// gravatar={false}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avatars are visual identifier, and we should aim for them to be as stable as possible. There should never be a case where a user avatar is displayed as a letter avatar on one page vs a gravatar on all the rest.

<Flex align="center" gap="md" paddingLeft="xs">
{hasAssigneeMatch && userForAvatar ? (
<UserAvatar user={userForAvatar} size={24} gravatar />
<UserAvatar user={userForAvatar} size={24} />
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avatars are visual identifier, and we should aim for them to be as stable as possible. There should never be a case where a user avatar is displayed as a letter avatar on one page vs a gravatar on all the rest.

return (
<StyledPanelItem key={commit.id} data-test-id="quick-context-commit-row">
<UserAvatar size={24} user={commit.author} />
{commit.author ? <UserAvatar size={24} user={commit.author} /> : null}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

null rendering was previously implicit when user was falsy

letterId: user.name,
title: user.name,
uploadUrl: '',
function getUserAvatarProps(
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is now much clearer that these are just thin wrappers around our Avatar component. It also opens up the case where we can say that these are Sentry specific, not scraps, and should live somewhere in the sentry/components folder.

@@ -1,28 +0,0 @@
import {render, screen} from 'sentry-test/reactTestingLibrary';
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These files have been moved to sub folders, as they are the building block that our entity avatars build on

@@ -1,97 +0,0 @@
import {useEffect, useState} from 'react';
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These files have been moved to sub folders, as they are the building block that our entity avatars build on

);
}

function AvatarList({
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was the last module that was using the default export

Comment on lines 41 to 53
export function Avatar({
ref,
className,
size,
style,
tooltip,
tooltipOptions,
hasTooltip = false,
...props
}: GravatarBaseAvatarProps | LetterBaseAvatarProps | UploadBaseAvatarProps) {
return (
<Tooltip title={tooltip} disabled={!hasTooltip} {...tooltipOptions} skipWrapper>
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avatar component is the abstraction over Gravatar, Letter or ImageAvatar and the one that all entity avatars use

return <Placeholder width={`${props.size}px`} height={`${props.size}px`} />;
}

if (!team) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This used to be implicit

Comment on lines -227 to -239
const gravatarActions = (
<AvatarActions>
<LinkButton
external
href="https://gravatar.com"
size="zero"
priority="transparent"
icon={<IconOpen />}
aria-label={t('Go to gravatar.com')}
title={t('Visit gravatar.com to upload your Gravatar to be used on Sentry.')}
/>
</AvatarActions>
);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The UX here is unnecessary and confusing. We already indicate in the option that users can control their gravatar at gravatar.com, and there is no reason to display a upload and a change button that do the same thing

Image

@JonasBa JonasBa force-pushed the jb/avatar/refactor branch from f597fbd to e8a0c03 Compare February 6, 2026 20:16
ref(baseAvatar) inline avatar

address comments

streamline hook call and  reset

ref(avatar) resolve error handling

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

update avatar chooser
@JonasBa JonasBa marked this pull request as ready for review February 6, 2026 20:51
@JonasBa JonasBa requested review from a team as code owners February 6, 2026 20:51
@JonasBa JonasBa marked this pull request as draft February 6, 2026 20:51
JonasBa and others added 2 commits February 6, 2026 13:04
Updated test expectations to match the new avatar behavior where initials
are generated from display names instead of slugs.

Changes:
- Team avatar: Expected initials "TS" → "TN" (from "Team Name" not "team-slug")
- Organization avatar: Expected initials "OS" → "ON" (from "Organization Name" not "org-slug")
- Changed test ID from "default-avatar" → "letter_avatar-avatar" to match new Avatar component behavior

This is the correct behavior - avatars should use human-readable names for
initials rather than technical slugs.
The Avatar component was not respecting custom data-test-id props passed
from parent components. This caused test failures in components that relied
on custom test IDs (e.g., 'assigned-avatar' in assignedTo.tsx).

Updated Avatar to accept and prefer custom data-test-id props while falling
back to the generated type-based ID when no custom ID is provided.
Comment on lines +45 to +50
className,
size,
style,
tooltip,
tooltipOptions,
hasTooltip = false,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will clean these up in a followup PR. I am thinking of moving it under tooltipProps and then codemoding it along with tooltipProps on our button component

@@ -1,6 +1,6 @@
import groupBy from 'lodash/groupBy';

import type {BaseAvatarProps} from '@sentry/scraps/avatar';
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BaseAvatar export from scraps was just bad naming.

width: 100%;
`;

const StyledAvatar = styled(BaseAvatar)`
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AvatarContainer already uses flexShrink: 0

/** Defines the styling interface for all avatar components */
export interface BaseAvatarStyleProps {
round?: boolean;
size?: number;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added here so that all avatars inherit it. I will followup with converting this to t-shirt sizes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Scope: Frontend Automatically applied to PRs that change frontend components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant