diff --git a/fixtures/js-stubs/feedbackIssue.tsx b/fixtures/js-stubs/feedbackIssue.tsx new file mode 100644 index 00000000000000..52fc5874fb5165 --- /dev/null +++ b/fixtures/js-stubs/feedbackIssue.tsx @@ -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 = Pick> & U; +type PartialMetadata = Partial; + +export function FeedbackIssueFixture( + params: Partial> +): 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, + }, + }; +} diff --git a/static/app/components/feedback/feedbackItem/feedbackItemUsername.spec.tsx b/static/app/components/feedback/feedbackItem/feedbackItemUsername.spec.tsx new file mode 100644 index 00000000000000..48bd65fecddca3 --- /dev/null +++ b/static/app/components/feedback/feedbackItem/feedbackItemUsername.spec.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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 '); + 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(); + + 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') + ); + }); +}); diff --git a/static/app/components/feedback/feedbackItem/feedbackItemUsername.tsx b/static/app/components/feedback/feedbackItem/feedbackItemUsername.tsx index 0d8ea249729fb8..d233c519a44814 100644 --- a/static/app/components/feedback/feedbackItem/feedbackItemUsername.tsx +++ b/static/app/components/feedback/feedbackItem/feedbackItemUsername.tsx @@ -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(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 {t('Anonymous User')}; } - if (detailDisplay) { - return ( - - - - {name ?? t('No Name')} - - - {email ?? t('No Email')} - - , - size: 'zero', - borderless: true, - showChevron: false, + return ( + + + { + 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, - }, - ]} - /> - - ); - } - - return {name ?? email}; + ref={userNodeRef} + > + {isSameNameAndEmail ? ( + {name ?? email} + ) : ( + + {name ?? t('No Name')} + + {email ?? t('No Email')} + + )} + + + {email ? ( + + } + aria-label={t(`Email %s`, user)} + borderless + size="zero" + /> + + ) : null} + + ); } -const FlexDropdownMenu = styled(DropdownMenu)` - display: flex; -`; - const Purple = styled('span')` color: ${p => p.theme.purple300}; - padding: ${space(0.5)}; `; diff --git a/static/app/components/feedback/feedbackItem/messageSection.tsx b/static/app/components/feedback/feedbackItem/messageSection.tsx index eecea5237cefc7..80cbcd8adc1a5c 100644 --- a/static/app/components/feedback/feedbackItem/messageSection.tsx +++ b/static/app/components/feedback/feedbackItem/messageSection.tsx @@ -24,7 +24,7 @@ export default function MessageSection({eventData, feedbackItem}: Props) { return ( - + ( - + + {feedbackItem.metadata.name ?? + feedbackItem.metadata.contact_email ?? + t('Anonymous User')} + diff --git a/static/app/utils/feedback/types.tsx b/static/app/utils/feedback/types.tsx index 55ebe016d49ae4..099442b7e1b0e5 100644 --- a/static/app/utils/feedback/types.tsx +++ b/static/app/utils/feedback/types.tsx @@ -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;