Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {EventFixture} from 'sentry-fixture/event';

import {render, screen} from 'sentry-test/reactTestingLibrary';
import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';

import ContextCard from 'sentry/components/events/contexts/contextCard';
import {
Expand Down Expand Up @@ -36,7 +36,21 @@

describe('UserContext', () => {
it('returns values and according to the parameters', () => {
expect(getUserContextData({data: MOCK_USER_CONTEXT})).toEqual([
const result = getUserContextData({data: MOCK_USER_CONTEXT});

// Extract the actionButton from the email item for comparison
const emailItem = result.find(item => item.key === 'email');
const {actionButton, ...emailItemWithoutButton} = emailItem || {};

Check failure on line 43 in static/app/components/events/contexts/knownContext/user.spec.tsx

View workflow job for this annotation

GitHub Actions / typescript

'emailItemWithoutButton' is declared but its value is never read.

expect(
result.map(item => {
if (item.key === 'email') {
const {actionButton: _, ...rest} = item;
return rest;
}
return item;
})
).toEqual([
{
key: 'email',
subject: 'Email',
Expand Down Expand Up @@ -65,6 +79,9 @@
meta: undefined,
},
]);

// Verify actionButton exists for email
expect(actionButton).toBeDefined();
});

it('renders with meta annotations correctly', () => {
Expand All @@ -90,4 +107,22 @@
expect(screen.getByText('Name')).toBeInTheDocument();
expect(screen.getByText(/redacted/)).toBeInTheDocument();
});

it('allows copying email to clipboard', async () => {
const mockCopy = jest.fn().mockResolvedValue('');
Object.assign(navigator, {
clipboard: {
writeText: mockCopy,
},
});

const event = EventFixture();
render(
<ContextCard event={event} type="default" alias="user" value={MOCK_USER_CONTEXT} />
);

const copyButton = screen.getByRole('button', {name: 'Copy email to clipboard'});
await userEvent.click(copyButton);
expect(mockCopy).toHaveBeenCalledWith('[email protected]');
});
});
32 changes: 32 additions & 0 deletions static/app/components/events/contexts/knownContext/user.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import {useCallback} from 'react';

import {Button} from 'sentry/components/core/button';
import {getContextKeys} from 'sentry/components/events/contexts/utils';
import {IconCopy} from 'sentry/icons';
import {t} from 'sentry/locale';
import type {KeyValueListData} from 'sentry/types/group';
import {defined} from 'sentry/utils';
import useCopyToClipboard from 'sentry/utils/useCopyToClipboard';

enum UserContextKeys {
ID = 'id',
Expand Down Expand Up @@ -104,6 +109,9 @@ export function getUserContextData({
? `mailto:${data.email}`
: undefined,
},
actionButton: defined(data.email) ? (
<CopyEmailButton email={data.email} />
) : undefined,
};
case UserContextKeys.GEO:
return {
Expand All @@ -125,3 +133,27 @@ export function getUserContextData({
.filter(item => defined(item.value) || defined(meta?.[item.key]))
);
}

function CopyEmailButton({email}: {email: string}) {
const {copy} = useCopyToClipboard();

const handleCopy = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
copy(email, {successMessage: t('Email copied to clipboard')});
},
[copy, email]
);

return (
<Button
aria-label={t('Copy email to clipboard')}
title={t('Copy email to clipboard')}
borderless
size="zero"
icon={<IconCopy size="xs" />}
onClick={handleCopy}
/>
);
}
20 changes: 20 additions & 0 deletions static/app/views/issueDetails/participantList.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,24 @@ describe('ParticipantList', () => {

expect(screen.queryByText('Individuals (2)')).not.toBeInTheDocument();
});

it('copies email to clipboard when email is clicked', async () => {
const mockCopy = jest.fn().mockResolvedValue('');
Object.assign(navigator, {
clipboard: {
writeText: mockCopy,
},
});

render(
<ParticipantList teams={[]} users={users} description="Participants">
Click Me
</ParticipantList>
);
await userEvent.click(screen.getByRole('button', {name: 'Click Me'}));
await waitFor(() => expect(screen.getByText('John Doe')).toBeVisible());
const email = screen.getByText('[email protected]');
await userEvent.click(email);
expect(mockCopy).toHaveBeenCalledWith('[email protected]');
});
});
29 changes: 27 additions & 2 deletions static/app/views/issueDetails/participantList.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {Fragment, useState} from 'react';
import {Fragment, useCallback, useState} from 'react';
import styled from '@emotion/styled';
import {AnimatePresence, motion} from 'framer-motion';

Expand All @@ -10,13 +10,24 @@ import {t, tn} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {Team} from 'sentry/types/organization';
import type {User} from 'sentry/types/user';
import useCopyToClipboard from 'sentry/utils/useCopyToClipboard';

interface ParticipantScrollboxProps {
teams: Team[];
users: User[];
}

function ParticipantScrollbox({users, teams}: ParticipantScrollboxProps) {
const {copy} = useCopyToClipboard();

const handleCopyEmail = useCallback(
(email: string, event: React.MouseEvent) => {
event.stopPropagation();
copy(email, {successMessage: t('Email copied to clipboard')});
},
[copy]
);

if (!users.length && !teams.length) {
return null;
}
Expand All @@ -41,7 +52,14 @@ function ParticipantScrollbox({users, teams}: ParticipantScrollboxProps) {
<UserAvatar user={user} size={28} />
<div>
{user.name}
<SubText>{user.email}</SubText>
{user.email === user.name ? null : (
<ClickableEmail
onClick={e => handleCopyEmail(user.email, e)}
title={t('Click to copy email')}
>
{user.email}
</ClickableEmail>
)}
</div>
</UserRow>
))}
Expand Down Expand Up @@ -153,3 +171,10 @@ const SubText = styled('div')`
color: ${p => p.theme.subText};
font-size: ${p => p.theme.fontSize.xs};
`;

const ClickableEmail = styled(SubText)`
cursor: pointer;
&:hover {
text-decoration: underline;
}
`;
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,19 @@ describe('ParticipantList', () => {
expect(await screen.findByText('#team-1')).toBeInTheDocument();
expect(await screen.findByText('#team-2')).toBeInTheDocument();
});

it('copies email to clipboard when email is clicked', async () => {
const mockCopy = jest.fn().mockResolvedValue('');
Object.assign(navigator, {
clipboard: {
writeText: mockCopy,
},
});

render(<ParticipantList users={users} />);
await userEvent.click(screen.getByText('JD'), {skipHover: true});
const email = await screen.findByText('[email protected]');
await userEvent.click(email);
expect(mockCopy).toHaveBeenCalledWith('[email protected]');
});
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {Fragment} from 'react';
import {Fragment, useCallback} from 'react';
import {useTheme} from '@emotion/react';
import styled from '@emotion/styled';

Expand All @@ -13,6 +13,7 @@ import {space} from 'sentry/styles/space';
import type {Team} from 'sentry/types/organization';
import type {AvatarUser, User} from 'sentry/types/user';
import {userDisplayName} from 'sentry/utils/formatters';
import useCopyToClipboard from 'sentry/utils/useCopyToClipboard';
import useOverlay from 'sentry/utils/useOverlay';

interface DropdownListProps {
Expand All @@ -34,6 +35,15 @@ export default function ParticipantList({

const theme = useTheme();
const showHeaders = users.length > 0 && teams && teams.length > 0;
const {copy} = useCopyToClipboard();

const handleCopyEmail = useCallback(
(email: string, event: React.MouseEvent) => {
event.stopPropagation();
copy(email, {successMessage: t('Email copied to clipboard')});
},
[copy]
);

return (
<div>
Expand Down Expand Up @@ -83,7 +93,12 @@ export default function ParticipantList({
<NameWrapper>
<div>{user.name}</div>
{user.email === user.name ? null : (
<SmallText>{user.email}</SmallText>
<ClickableEmail
onClick={e => handleCopyEmail(user.email, e)}
title={t('Click to copy email')}
>
{user.email}
</ClickableEmail>
)}
{!hideTimestamp && <LastSeen date={(user as AvatarUser).lastSeen} />}
</NameWrapper>
Expand Down Expand Up @@ -144,6 +159,13 @@ const SmallText = styled('div')`
font-size: ${p => p.theme.fontSize.xs};
`;

const ClickableEmail = styled(SmallText)`
cursor: pointer;
&:hover {
text-decoration: underline;
}
`;

const StyledAvatarList = styled(AvatarList)`
justify-content: flex-end;
padding-left: ${space(0.75)};
Expand Down
Loading