Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 10 additions & 67 deletions static/app/components/avatarChooser/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import {useState} from 'react';
import styled from '@emotion/styled';

import {OrganizationAvatar, SentryAppAvatar, UserAvatar} from '@sentry/scraps/avatar';
import type {BaseAvatarProps} from '@sentry/scraps/avatar';
import {Button, LinkButton} from '@sentry/scraps/button';
import {Flex, Stack} from '@sentry/scraps/layout';
import type {AvatarProps} from '@sentry/scraps/avatar';
import {Button} from '@sentry/scraps/button';
import {Container, Flex, Stack} from '@sentry/scraps/layout';
import {ExternalLink} from '@sentry/scraps/link';

import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
Expand All @@ -14,7 +14,7 @@ import {Hovercard} from 'sentry/components/hovercard';
import Panel from 'sentry/components/panels/panel';
import PanelFooter from 'sentry/components/panels/panelFooter';
import PanelHeader from 'sentry/components/panels/panelHeader';
import {IconImage, IconOpen, IconUpload} from 'sentry/icons';
import {IconUpload} from 'sentry/icons';
import {t, tct} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {Avatar} from 'sentry/types/core';
Expand Down Expand Up @@ -212,54 +212,19 @@ function AvatarChooser({
const choices = options.filter(([key]) => supportedTypes.includes(key));

const uploadActions = (
<AvatarActions>
<Container position="absolute" bottom="-6px" margin="auto">
<Button
aria-label={t('Replace image')}
title={t('Replace image')}
size="zero"
priority="transparent"
aria-label={t('Upload')}
icon={<IconUpload />}
onClick={openUpload}
/>
</AvatarActions>
);

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>
);
Comment on lines -227 to -239
Copy link
Copy Markdown
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


const emptyGravatar = (
<BlankAvatar>
<IconImage size="xl" />
</BlankAvatar>
);

const emptyUploader = (
<Flex justify="center" align="center" height="100%">
<Button size="xs" icon={<IconUpload />} onClick={openUpload}>
size="xs"
>
{t('Upload')}
</Button>
</Flex>
</Container>
);

const backupAvatars: Partial<Record<AvatarType, React.ReactNode>> = {
gravatar: emptyGravatar,
upload: emptyUploader,
};

const sharedAvatarProps: Partial<Omit<BaseAvatarProps, 'ref'>> = {
type: avatarType,
backupAvatar: backupAvatars[avatarType],
const sharedAvatarProps: Partial<Omit<AvatarProps, 'ref'>> = {
size: 90,
};

Expand Down Expand Up @@ -331,7 +296,6 @@ function AvatarChooser({
>
<AvatarPreview>
{avatarPreview}
{avatarType === 'gravatar' && gravatarActions}
{avatarType === 'upload' && !disabled && uploadActions}
</AvatarPreview>
</CropperHovercard>
Expand Down Expand Up @@ -416,25 +380,4 @@ const AvatarHelp = styled('p')`
width: 50%;
`;

const BlankAvatar = styled('div')`
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: ${p => p.theme.colors.gray200};
background: ${p => p.theme.tokens.background.secondary};
height: 90px;
width: 90px;
`;

const AvatarActions = styled('div')`
position: absolute;
top: ${space(0.25)};
right: ${space(0.25)};
display: flex;
background: ${p => p.theme.colors.surface200};
padding: ${space(0.25)};
border-radius: 3px;
`;

export default AvatarChooser;
12 changes: 7 additions & 5 deletions static/app/components/commitRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,11 @@ function CommitRow({
<Message>{formatCommitMessage(commit.message)}</Message>
)}
<MetaWrapper>
{customAvatar ? customAvatar : <UserAvatar size={16} user={commit.author} />}
{customAvatar ? (
customAvatar
) : commit.author ? (
<UserAvatar size={16} user={commit.author} />
) : null}
Copy link
Copy Markdown
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

<Meta hasStreamlinedUI>
<Tooltip
title={tct(
Expand Down Expand Up @@ -193,16 +197,14 @@ function CommitRow({
</EmailWarning>
}
>
<UserAvatar size={36} user={commit.author} />
{commit.author ? <UserAvatar size={36} user={commit.author} /> : null}
Copy link
Copy Markdown
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

<EmailWarningIcon data-test-id="email-warning">
<IconWarning size="xs" />
</EmailWarningIcon>
</Hovercard>
</AvatarWrapper>
) : (
<div>
<UserAvatar size={36} user={commit.author} />
</div>
<div>{commit.author ? <UserAvatar size={36} user={commit.author} /> : null}</div>
Copy link
Copy Markdown
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

)}

<CommitMessage>
Expand Down
45 changes: 22 additions & 23 deletions static/app/components/core/avatar/actorAvatar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type React from 'react';
import {useMemo} from 'react';
import * as Sentry from '@sentry/react';

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

import {BaseAvatar, type BaseAvatarProps} from './baseAvatar';
import {Avatar, type AvatarProps} from './avatar';
import {TeamAvatar, type TeamAvatarProps} from './teamAvatar';
import {UserAvatar, type UserAvatarProps} from './userAvatar';

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

export interface ActorAvatarProps extends BaseAvatarProps {
export interface ActorAvatarProps extends Omit<AvatarProps, 'round'> {
actor: SimpleActor;
ref?: React.Ref<HTMLSpanElement | SVGSVGElement | HTMLImageElement>;
}

export function ActorAvatar({
ref,
size = 24,
hasTooltip = true,
actor,
Expand All @@ -35,11 +32,11 @@ export function ActorAvatar({
};

if (actor.type === 'user') {
return <AsyncMemberAvatar userActor={actor} {...otherProps} ref={ref} />;
return <AsyncMemberAvatar actor={actor} {...otherProps} />;
}

if (actor.type === 'team') {
return <AsyncTeamAvatar teamId={actor.id} {...otherProps} ref={ref} />;
return <AsyncTeamAvatar teamId={actor.id} {...otherProps} />;
}

Sentry.withScope(scope => {
Expand All @@ -56,33 +53,34 @@ export function ActorAvatar({

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

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

if (isLoading) {
const size = `${props.size}px`;
return <Placeholder width={size} height={size} />;
return <Placeholder width={`${props.size}px`} height={`${props.size}px`} />;
}

if (!team) {
Copy link
Copy Markdown
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

return <Avatar type="letter_avatar" name={teamId} identifier={teamId} {...props} />;
}

return <TeamAvatar team={team} {...props} ref={ref} />;
return <TeamAvatar team={team} {...props} />;
}

/**
* Wrapper to assist loading the user from api or store
*/
interface AsyncMemberAvatarProps extends Omit<UserAvatarProps, 'user'> {
userActor: SimpleActor;
ref?: React.Ref<HTMLSpanElement | SVGSVGElement | HTMLImageElement>;
interface AsyncMemberAvatarProps extends Omit<UserAvatarProps, 'user' | 'round'> {
actor: SimpleActor;
}

function AsyncMemberAvatar({ref, userActor, ...props}: AsyncMemberAvatarProps) {
const ids = useMemo(() => [userActor.id], [userActor.id]);
function AsyncMemberAvatar({actor, ...props}: AsyncMemberAvatarProps) {
const ids = useMemo(() => [actor.id], [actor.id]);
const {members, fetching} = useMembers({ids});
const member = members.find(u => u.id === userActor.id);
const member = members.find(u => u.id === actor.id);

if (fetching) {
const size = `${props.size}px`;
Expand All @@ -91,14 +89,15 @@ function AsyncMemberAvatar({ref, userActor, ...props}: AsyncMemberAvatarProps) {

if (!member) {
return (
<BaseAvatar
ref={ref}
size={props.size}
title={userActor.name ?? userActor.email}
<Avatar
{...props}
type="letter_avatar"
name={actor.name ?? actor.email ?? actor.id}
identifier={actor.id}
round
/>
);
}

return <UserAvatar user={member} {...props} ref={ref} />;
return <UserAvatar user={member} {...props} />;
}
11 changes: 0 additions & 11 deletions static/app/components/core/avatar/avatar.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -130,17 +130,6 @@ There are multiple avatar components which represent users, teams, organizations
<DocIntegrationAvatar size={PREVIEW_SIZE} docIntegration={DOC_INTEGRATION} />
</Storybook.Demo>

## Props

All avatar components accept common props, to customize `size` and render tooltips with `hasTooltip`, `tooltip`, and `tooltipOptions`.

<Storybook.Demo>
<UserAvatar user={USER} size={64} hasTooltip tooltip="This avatar has a tooltip" />
</Storybook.Demo>
```jsx
<UserAvatar user={user} size={64} hasTooltip tooltip="This avatar has a tooltip" />
```

## Types

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.
Expand Down
109 changes: 109 additions & 0 deletions static/app/components/core/avatar/avatar.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import {render, screen} from 'sentry-test/reactTestingLibrary';

// eslint-disable-next-line boundaries/entry-point
import {Avatar} from './avatar';

describe('Avatar', () => {
describe('upload URL size parameter', () => {
it('appends ?s=120 to upload URLs', () => {
render(
<Avatar
type="upload"
uploadUrl="https://example.com/avatar.jpg"
identifier="test-id"
name="Test User"
/>
);
const img = screen.getByRole('img');
expect(img).toHaveAttribute('src', 'https://example.com/avatar.jpg?s=120');
});

it('appends &s=120 to upload URLs with existing query params', () => {
render(
<Avatar
type="upload"
uploadUrl="https://example.com/avatar.jpg?version=2"
identifier="test-id"
name="Test User"
/>
);
const img = screen.getByRole('img');
expect(img).toHaveAttribute(
'src',
'https://example.com/avatar.jpg?version=2&s=120'
);
});

it('does not modify data URLs', () => {
const dataUrl =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
render(
<Avatar type="upload" uploadUrl={dataUrl} identifier="test-id" name="Test User" />
);
const img = screen.getByRole('img');
// Data URLs should not have size parameter appended
expect(img).toHaveAttribute('src', dataUrl);
});
});

describe('avatar type rendering', () => {
it('renders ImageAvatar for upload type', () => {
render(
<Avatar
type="upload"
uploadUrl="https://example.com/avatar.jpg"
identifier="test-id"
name="Test User"
/>
);
expect(screen.getByRole('img')).toBeInTheDocument();
});

it('renders LetterAvatar for letter_avatar type', () => {
render(<Avatar type="letter_avatar" identifier="test-id" name="Test User" />);
expect(screen.getByText('TU')).toBeInTheDocument();
expect(screen.queryByRole('img')).not.toBeInTheDocument();
});

it('renders Gravatar for gravatar type', async () => {
render(
<Avatar
type="gravatar"
gravatarId="test@example.com"
identifier="test-id"
name="Test User"
/>
);
// Gravatar hashes asynchronously, so use findBy
expect(await screen.findByRole('img')).toBeInTheDocument();
});
});

describe('tooltip', () => {
it('shows tooltip when hasTooltip=true', () => {
render(
<Avatar
type="letter_avatar"
identifier="test-id"
name="Test User"
tooltip="Test Tooltip"
hasTooltip
/>
);
expect(screen.getByText('TU')).toBeInTheDocument();
});

it('does not show tooltip when hasTooltip=false', () => {
render(
<Avatar
type="letter_avatar"
identifier="test-id"
name="Test User"
tooltip="Test Tooltip"
hasTooltip={false}
/>
);
expect(screen.getByText('TU')).toBeInTheDocument();
});
});
});
Loading
Loading