Skip to content

Commit

Permalink
feat(feedback): Add a mailto link, and make it easy to copy an email …
Browse files Browse the repository at this point in the history
…address in Feedback Details (#68666)

There's a new send-email button that uses a `mailto` to open your email
client and let you start typing.
<img width="405" alt="SCR-20240410-nufl"
src="https://github.com/getsentry/sentry/assets/187460/d006f918-85d3-410a-b14f-06171c430bc5">

Clicking on the name/email text will highlight all of it in blue (as
shown) and copy "Josh Ferge <josh.ferge@sentry.io>" onto the clipboard:
<img width="320" alt="SCR-20240410-nufy"
src="https://github.com/getsentry/sentry/assets/187460/c8affee4-8dd3-4be6-acc3-d071941d52fa">

It's standard format, mail clients will allow you to paste it. It's nice
because it includes both parts in a well formatted structure (i
generally find it's easier to remove extra bits if needed compared to
having to copy+paste more often).

Also, this change makes it so if someone types the same thing into the
name & email fields we'll only show it once.


Fixes getsentry/team-replay#414
  • Loading branch information
ryan953 committed Apr 11, 2024
1 parent 299e2de commit 5c60991
Show file tree
Hide file tree
Showing 6 changed files with 243 additions and 85 deletions.
76 changes: 76 additions & 0 deletions fixtures/js-stubs/feedbackIssue.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import {ProjectFixture} from 'sentry-fixture/project';

import {EventOrGroupType, GroupStatus, PriorityLevel} from 'sentry/types';
import type {FeedbackIssue} from 'sentry/utils/feedback/types';

type Overwrite<T, U> = Pick<T, Exclude<keyof T, keyof U>> & U;
type PartialMetadata = Partial<FeedbackIssue['metadata']>;

export function FeedbackIssueFixture(
params: Partial<Overwrite<FeedbackIssue, {metadata: PartialMetadata}>>
): FeedbackIssue {
return {
id: '5146636313',
shareId: '',
shortId: 'JAVASCRIPT-2SDJ',
title: 'User Feedback',
culprit: 'user',
permalink:
'https://sentry.sentry.io/feedback/?feedbackSlug=javascript%3A5146636313&project=11276',
logger: null,
level: 'info',
status: GroupStatus.UNRESOLVED,
statusDetails: {},
substatus: null,
isPublic: false,
platform: 'javascript',
project: ProjectFixture({
platform: 'javascript',
}),
type: EventOrGroupType.GENERIC,
filtered: null,
numComments: 0,
assignedTo: null,
isBookmarked: false,
isSubscribed: false,
subscriptionDetails: {
disabled: true,
},
hasSeen: true,
annotations: [],
issueType: 'feedback',
issueCategory: 'feedback',
priority: PriorityLevel.MEDIUM,
priorityLockedAt: null,
isUnhandled: false,
count: '1',
userCount: 1,
firstSeen: '2024-04-05T20:05:02.938000Z',
lastSeen: '2024-04-05T20:05:02Z',
inbox: null,
owners: null,
activity: [],
seenBy: [],
pluginActions: [],
pluginIssues: [],
pluginContexts: [],
userReportCount: 0,
stats: {},
participants: [],
...params,
metadata: {
title: 'User Feedback',
value: 'feedback test 4',
initial_priority: 50,
contact_email: 'josh.ferge@sentry.io',
message: 'feedback test 4',
name: 'Josh Ferge',
source: 'new_feedback_envelope',
sdk: {
name: 'sentry.javascript.react',
name_normalized: 'sentry.javascript.react',
},
...params.metadata,
},
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import {FeedbackIssueFixture} from 'sentry-fixture/feedbackIssue';

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

import FeedbackItemUsername from 'sentry/components/feedback/feedbackItem/feedbackItemUsername';

describe('FeedbackItemUsername', () => {
it('should fallback to "Anonymous User" when no name/contact_email exist', () => {
const issue = FeedbackIssueFixture({
metadata: {
name: null,
contact_email: null,
},
});
render(<FeedbackItemUsername feedbackIssue={issue} />);

expect(screen.getByText('Anonymous User')).toBeInTheDocument();
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});

it('should show name if that is all that exists', () => {
const issue = FeedbackIssueFixture({
metadata: {
name: 'Foo Bar',
contact_email: null,
},
});
render(<FeedbackItemUsername feedbackIssue={issue} />);

expect(screen.getByText('Foo Bar')).toBeInTheDocument();
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});

it('should show contact_email if that is all that exists', () => {
const issue = FeedbackIssueFixture({
metadata: {
name: null,
contact_email: 'foo@bar.com',
},
});
render(<FeedbackItemUsername feedbackIssue={issue} />);

expect(screen.getByText('foo@bar.com')).toBeInTheDocument();

const mailtoButton = screen.getByRole('button');
expect(mailtoButton).toHaveAttribute('aria-label', 'Email foo@bar.com');
expect(mailtoButton).toHaveAttribute(
'href',
expect.stringContaining('mailto:foo@bar.com')
);
});

it('should show both name and contact_email if they are set', () => {
const issue = FeedbackIssueFixture({
metadata: {
name: 'Foo Bar',
contact_email: 'foo@bar.com',
},
});
render(<FeedbackItemUsername feedbackIssue={issue} />);

expect(screen.getByText('Foo Bar')).toBeInTheDocument();
expect(screen.getByText('foo@bar.com')).toBeInTheDocument();

const mailtoButton = screen.getByRole('button');
expect(mailtoButton).toHaveAttribute('aria-label', 'Email Foo Bar <foo@bar.com>');
expect(mailtoButton).toHaveAttribute(
'href',
expect.stringContaining('mailto:foo@bar.com')
);
});

it('should not show duplicate name & contact_email if they are the same value', () => {
const issue = FeedbackIssueFixture({
metadata: {
name: 'foo@bar.com',
contact_email: 'foo@bar.com',
},
});
render(<FeedbackItemUsername feedbackIssue={issue} />);

expect(screen.getAllByText('foo@bar.com')).toHaveLength(1);

const mailtoButton = screen.getByRole('button');
expect(mailtoButton).toHaveAttribute('aria-label', 'Email foo@bar.com');
expect(mailtoButton).toHaveAttribute(
'href',
expect.stringContaining('mailto:foo@bar.com')
);
});
});
149 changes: 68 additions & 81 deletions static/app/components/feedback/feedbackItem/feedbackItemUsername.tsx
Original file line number Diff line number Diff line change
@@ -1,110 +1,97 @@
import type {CSSProperties} from 'react';
import {css} from '@emotion/react';
import {type CSSProperties, Fragment, useCallback, useRef} from 'react';
import {findDOMNode} from 'react-dom';
import styled from '@emotion/styled';

import {DropdownMenu} from 'sentry/components/dropdownMenu';
import {LinkButton} from 'sentry/components/button';
import {Flex} from 'sentry/components/profiling/flex';
import {IconChevron} from 'sentry/icons';
import {Tooltip} from 'sentry/components/tooltip';
import {IconMail} from 'sentry/icons';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {FeedbackIssue} from 'sentry/utils/feedback/types';
import {selectText} from 'sentry/utils/selectText';
import useCopyToClipboard from 'sentry/utils/useCopyToClipboard';

interface Props {
detailDisplay: boolean;
feedbackIssue: FeedbackIssue;
className?: string;
style?: CSSProperties;
}

const hideDropdown = css`
button[aria-haspopup] {
display: block;
opacity: 0;
transition: opacity 50ms linear;
}
&:hover button[aria-haspopup],
button[aria-expanded='true'],
button[aria-haspopup].focus-visible {
opacity: 1;
}
`;

export default function FeedbackItemUsername({
className,
detailDisplay,
feedbackIssue,
style,
}: Props) {
export default function FeedbackItemUsername({className, feedbackIssue, style}: Props) {
const name = feedbackIssue.metadata.name;
const email = feedbackIssue.metadata.contact_email;

const {onClick: handleCopyEmail} = useCopyToClipboard({
successMessage: t('Copied Email to clipboard'),
text: String(email),
});
const nameOrEmail = name || email;
const isSameNameAndEmail = name === email;

const user = name && email && !isSameNameAndEmail ? `${name} <${email}>` : nameOrEmail;

const userNodeRef = useRef<HTMLInputElement>(null);

const handleSelectText = useCallback(() => {
if (!userNodeRef.current) {
return;
}

const {onClick: handleCopyUsername} = useCopyToClipboard({
successMessage: t('Copied Name to clipboard'),
text: String(name),
// We use findDOMNode here because `this.userNodeRef` is not a dom node,
// it's a ref to AutoSelectText
const node = findDOMNode(userNodeRef.current); // eslint-disable-line react/no-find-dom-node
if (!node || !(node instanceof HTMLElement)) {
return;
}

selectText(node);
}, []);

const {onClick: handleCopyToClipboard} = useCopyToClipboard({
text: user ?? '',
});

if (!email && !name) {
if (!name && !email) {
return <strong>{t('Anonymous User')}</strong>;
}

if (detailDisplay) {
return (
<Flex
align="center"
gap={space(1)}
className={className}
style={style}
css={hideDropdown}
>
<Flex align="center" wrap="wrap">
<strong>
{name ?? t('No Name')}
<Purple></Purple>
</strong>
<strong>{email ?? t('No Email')}</strong>
</Flex>
<FlexDropdownMenu
triggerProps={{
'aria-label': t('Short-ID copy actions'),
icon: <IconChevron direction="down" size="xs" />,
size: 'zero',
borderless: true,
showChevron: false,
return (
<Flex align="center" gap={space(1)} className={className} style={style}>
<Tooltip title={t('Click to copy')} containerDisplayMode="flex">
<Flex
align="center"
wrap="wrap"
gap={space(0.5)}
onClick={() => {
handleSelectText();
handleCopyToClipboard();
}}
position="bottom"
size="xs"
items={[
{
key: 'copy-email',
label: t('Copy Email Address'),
onAction: handleCopyEmail,
},
{
key: 'copy-name',
label: t('Copy Name'),
onAction: handleCopyUsername,
},
]}
/>
</Flex>
);
}

return <strong>{name ?? email}</strong>;
ref={userNodeRef}
>
{isSameNameAndEmail ? (
<strong>{name ?? email}</strong>
) : (
<Fragment>
<strong>{name ?? t('No Name')}</strong>
<Purple></Purple>
<strong>{email ?? t('No Email')}</strong>
</Fragment>
)}
</Flex>
</Tooltip>
{email ? (
<Tooltip title={t(`Email %s`, user)} containerDisplayMode="flex">
<LinkButton
href={`mailto:${email}`}
external
icon={<IconMail color="gray300" />}
aria-label={t(`Email %s`, user)}
borderless
size="zero"
/>
</Tooltip>
) : null}
</Flex>
);
}

const FlexDropdownMenu = styled(DropdownMenu)`
display: flex;
`;

const Purple = styled('span')`
color: ${p => p.theme.purple300};
padding: ${space(0.5)};
`;
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export default function MessageSection({eventData, feedbackItem}: Props) {
return (
<Fragment>
<Flex wrap="wrap" flex="1 1 auto" gap={space(1)} justify="space-between">
<FeedbackItemUsername feedbackIssue={feedbackItem} detailDisplay />
<FeedbackItemUsername feedbackIssue={feedbackItem} />

<StyledTimeSince
date={feedbackItem.firstSeen}
Expand Down
7 changes: 5 additions & 2 deletions static/app/components/feedback/list/feedbackListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import styled from '@emotion/styled';
import ActorAvatar from 'sentry/components/avatar/actorAvatar';
import ProjectAvatar from 'sentry/components/avatar/projectAvatar';
import Checkbox from 'sentry/components/checkbox';
import FeedbackItemUsername from 'sentry/components/feedback/feedbackItem/feedbackItemUsername';
import IssueTrackingSignals from 'sentry/components/feedback/list/issueTrackingSignals';
import InteractionStateLayer from 'sentry/components/interactionStateLayer';
import Link from 'sentry/components/links/link';
Expand Down Expand Up @@ -95,7 +94,11 @@ const FeedbackListItem = forwardRef<HTMLDivElement, Props>(
</Row>

<TextOverflow style={{gridArea: 'user'}}>
<FeedbackItemUsername feedbackIssue={feedbackItem} detailDisplay={false} />
<strong>
{feedbackItem.metadata.name ??
feedbackItem.metadata.contact_email ??
t('Anonymous User')}
</strong>
</TextOverflow>

<TimeSince date={feedbackItem.firstSeen} style={{gridArea: 'time'}} />
Expand Down
3 changes: 2 additions & 1 deletion static/app/utils/feedback/types.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ export type FeedbackIssue = Overwrite<
metadata: {
contact_email: null | string;
message: string;
name: string;
name: null | string;
title: string;
value: string;
initial_priority?: number;
sdk?: {
name: string;
name_normalized: string;
Expand Down

0 comments on commit 5c60991

Please sign in to comment.