diff --git a/static/app/components/feedback/feedbackItem/feedbackActions.tsx b/static/app/components/feedback/feedbackItem/feedbackActions.tsx
index eb4e4a8c897f0e..0be089600acb25 100644
--- a/static/app/components/feedback/feedbackItem/feedbackActions.tsx
+++ b/static/app/components/feedback/feedbackItem/feedbackActions.tsx
@@ -1,5 +1,5 @@
import type {CSSProperties} from 'react';
-import {Fragment} from 'react';
+import {Fragment, useCallback} from 'react';
import {Button} from 'sentry/components/core/button';
import {Flex} from 'sentry/components/core/layout';
@@ -8,11 +8,14 @@ import {DropdownMenu} from 'sentry/components/dropdownMenu';
import ErrorBoundary from 'sentry/components/errorBoundary';
import FeedbackAssignedTo from 'sentry/components/feedback/feedbackItem/feedbackAssignedTo';
import useFeedbackActions from 'sentry/components/feedback/feedbackItem/useFeedbackActions';
-import {IconEllipsis} from 'sentry/icons';
+import {IconCopy, IconEllipsis} from 'sentry/icons';
import {t} from 'sentry/locale';
import type {Event} from 'sentry/types/event';
import type {Group} from 'sentry/types/group';
+import {trackAnalytics} from 'sentry/utils/analytics';
import type {FeedbackIssue} from 'sentry/utils/feedback/types';
+import useCopyToClipboard from 'sentry/utils/useCopyToClipboard';
+import useOrganization from 'sentry/utils/useOrganization';
interface Props {
eventData: Event | undefined;
@@ -29,6 +32,47 @@ export default function FeedbackActions({
size,
style,
}: Props) {
+ const organization = useOrganization();
+ const {copy} = useCopyToClipboard();
+ const handleCopyToClipboard = useCallback(() => {
+ const summary = feedbackItem.metadata.summary;
+ const message =
+ feedbackItem.metadata.message ?? feedbackItem.metadata.value ?? t('No message');
+ const culprit = eventData?.culprit?.trim();
+ const viewNames = eventData?.contexts?.app?.view_names?.filter(Boolean);
+
+ const sourceLines = [];
+ if (culprit) {
+ sourceLines.push(`- ${culprit}`);
+ }
+ if (viewNames?.length) {
+ sourceLines.push(t('- View names: %s', viewNames.join(', ')));
+ }
+
+ const markdown = [
+ '# User Feedback',
+ '',
+ ...(summary ? [`**Summary:** ${summary}`, ''] : []),
+ '## Feedback Message',
+ message,
+ ...(sourceLines.length
+ ? [
+ '',
+ '## Source (_where user was when feedback was sent_)',
+ sourceLines.join('\n'),
+ ]
+ : []),
+ ].join('\n');
+
+ trackAnalytics('feedback.feedback-item-copy-as-markdown', {
+ organization,
+ });
+
+ copy(markdown, {
+ successMessage: t('Copied feedback'),
+ errorMessage: t('Failed to copy feedback'),
+ });
+ }, [copy, eventData, feedbackItem, organization]);
if (!eventData) {
return null;
}
@@ -42,14 +86,35 @@ export default function FeedbackActions({
/>
- {size === 'large' ? : null}
- {size === 'medium' ? : null}
- {size === 'small' ? : null}
+ {size === 'large' ? (
+
+ ) : null}
+ {size === 'medium' ? (
+
+ ) : null}
+ {size === 'small' ? (
+
+ ) : null}
);
}
-function LargeWidth({feedbackItem}: {feedbackItem: FeedbackIssue}) {
+function LargeWidth({
+ feedbackItem,
+ onCopyToClipboard,
+}: {
+ feedbackItem: FeedbackIssue;
+ onCopyToClipboard: () => void;
+}) {
const {
enableDelete,
onDelete,
@@ -82,6 +147,15 @@ function LargeWidth({feedbackItem}: {feedbackItem: FeedbackIssue}) {
{hasSeen ? t('Mark Unread') : t('Mark Read')}
+
+ }
+ onClick={onCopyToClipboard}
+ aria-label={t('Copy feedback as markdown')}
+ />
+
void;
+}) {
const {
enableDelete,
onDelete,
@@ -140,6 +220,12 @@ function MediumWidth({feedbackItem}: {feedbackItem: FeedbackIssue}) {
? undefined
: t('You must be a member of the project'),
},
+ {
+ key: 'copy',
+ label: t('Copy as markdown'),
+ onAction: onCopyToClipboard,
+ tooltip: t('Copy feedback as markdown'),
+ },
{
key: 'delete',
priority: 'danger' as const,
@@ -156,7 +242,13 @@ function MediumWidth({feedbackItem}: {feedbackItem: FeedbackIssue}) {
);
}
-function SmallWidth({feedbackItem}: {feedbackItem: FeedbackIssue}) {
+function SmallWidth({
+ feedbackItem,
+ onCopyToClipboard,
+}: {
+ feedbackItem: FeedbackIssue;
+ onCopyToClipboard: () => void;
+}) {
const {
enableDelete,
onDelete,
@@ -189,6 +281,12 @@ function SmallWidth({feedbackItem}: {feedbackItem: FeedbackIssue}) {
label: isSpam ? t('Move to Inbox') : t('Mark as Spam'),
onAction: onSpamClick,
},
+ {
+ key: 'copy',
+ label: t('Copy as markdown'),
+ onAction: onCopyToClipboard,
+ tooltip: t('Copy feedback as markdown'),
+ },
{
key: 'read',
label: hasSeen ? t('Mark Unread') : t('Mark Read'),
diff --git a/static/app/utils/analytics/feedbackAnalyticsEvents.tsx b/static/app/utils/analytics/feedbackAnalyticsEvents.tsx
index bc07f8bf7c23c6..ba2ce798ee0888 100644
--- a/static/app/utils/analytics/feedbackAnalyticsEvents.tsx
+++ b/static/app/utils/analytics/feedbackAnalyticsEvents.tsx
@@ -2,6 +2,7 @@ export type FeedbackEventParameters = {
'feedback.details-integration-issue-clicked': {
integration_key: string;
};
+ 'feedback.feedback-item-copy-as-markdown': Record;
'feedback.feedback-item-not-found': {feedbackId: string};
'feedback.feedback-item-rendered': Record;
'feedback.index-setup-viewed': Record;
@@ -28,6 +29,7 @@ export type FeedbackEventParameters = {
type FeedbackEventKey = keyof FeedbackEventParameters;
export const feedbackEventMap: Record = {
+ 'feedback.feedback-item-copy-as-markdown': 'Copied Feedback Item as Markdown',
'feedback.feedback-item-not-found': 'Feedback item not found',
'feedback.feedback-item-rendered': 'Loaded and rendered a feedback item',
'feedback.index-setup-viewed': 'Viewed Feedback Onboarding Setup',