feedbackItem.tsx 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256
  1. import {Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import {
  4. addErrorMessage,
  5. addLoadingMessage,
  6. addSuccessMessage,
  7. } from 'sentry/actionCreators/indicator';
  8. import Button from 'sentry/components/actions/button';
  9. import ProjectAvatar from 'sentry/components/avatar/projectAvatar';
  10. import {DropdownMenu} from 'sentry/components/dropdownMenu';
  11. import ErrorBoundary from 'sentry/components/errorBoundary';
  12. import CrashReportSection from 'sentry/components/feedback/feedbackItem/crashReportSection';
  13. import FeedbackAssignedTo from 'sentry/components/feedback/feedbackItem/feedbackAssignedTo';
  14. import Section from 'sentry/components/feedback/feedbackItem/feedbackItemSection';
  15. import FeedbackItemUsername from 'sentry/components/feedback/feedbackItem/feedbackItemUsername';
  16. import FeedbackViewers from 'sentry/components/feedback/feedbackItem/feedbackViewers';
  17. import IssueTrackingSection from 'sentry/components/feedback/feedbackItem/issueTrackingSection';
  18. import ReplaySection from 'sentry/components/feedback/feedbackItem/replaySection';
  19. import TagsSection from 'sentry/components/feedback/feedbackItem/tagsSection';
  20. import useFeedbackHasReplayId from 'sentry/components/feedback/useFeedbackHasReplayId';
  21. import useMutateFeedback from 'sentry/components/feedback/useMutateFeedback';
  22. import PanelItem from 'sentry/components/panels/panelItem';
  23. import {Flex} from 'sentry/components/profiling/flex';
  24. import TextCopyInput from 'sentry/components/textCopyInput';
  25. import TextOverflow from 'sentry/components/textOverflow';
  26. import {IconChevron, IconLink} from 'sentry/icons';
  27. import {t} from 'sentry/locale';
  28. import {space} from 'sentry/styles/space';
  29. import type {Event, Group} from 'sentry/types';
  30. import {GroupStatus} from 'sentry/types';
  31. import type {FeedbackIssue} from 'sentry/utils/feedback/types';
  32. import useCopyToClipboard from 'sentry/utils/useCopyToClipboard';
  33. import useOrganization from 'sentry/utils/useOrganization';
  34. import {normalizeUrl} from 'sentry/utils/withDomainRequired';
  35. interface Props {
  36. eventData: Event | undefined;
  37. feedbackItem: FeedbackIssue;
  38. tags: Record<string, string>;
  39. }
  40. export default function FeedbackItem({feedbackItem, eventData, tags}: Props) {
  41. const organization = useOrganization();
  42. const hasReplayId = useFeedbackHasReplayId({feedbackId: feedbackItem.id});
  43. const {markAsRead, resolve} = useMutateFeedback({
  44. feedbackIds: [feedbackItem.id],
  45. organization,
  46. });
  47. const url = eventData?.tags.find(tag => tag.key === 'url');
  48. const replayId = eventData?.contexts?.feedback?.replay_id;
  49. const mutationOptions = {
  50. onError: () => {
  51. addErrorMessage(t('An error occurred while updating the feedback.'));
  52. },
  53. onSuccess: () => {
  54. addSuccessMessage(t('Updated feedback'));
  55. },
  56. };
  57. const crashReportId = eventData?.contexts?.feedback?.associated_event_id;
  58. const feedbackUrl =
  59. window.location.origin +
  60. normalizeUrl(
  61. `/organizations/${organization.slug}/feedback/?feedbackSlug=${feedbackItem.project.slug}:${feedbackItem.id}&project=${feedbackItem.project.id}`
  62. );
  63. const {onClick: handleCopyUrl} = useCopyToClipboard({
  64. successMessage: t('Copied Feedback URL to clipboard'),
  65. text: feedbackUrl,
  66. });
  67. const {onClick: handleCopyShortId} = useCopyToClipboard({
  68. successMessage: t('Copied Short-ID to clipboard'),
  69. text: feedbackItem.shortId,
  70. });
  71. return (
  72. <Fragment>
  73. <HeaderPanelItem>
  74. <Flex gap={space(2)} justify="space-between" wrap="wrap">
  75. <Flex column>
  76. <Flex align="center" gap={space(0.5)}>
  77. <FeedbackItemUsername feedbackIssue={feedbackItem} detailDisplay />
  78. </Flex>
  79. <Flex gap={space(0.5)} align="center">
  80. <ProjectAvatar
  81. project={feedbackItem.project}
  82. size={12}
  83. title={feedbackItem.project.slug}
  84. />
  85. <TextOverflow>{feedbackItem.shortId}</TextOverflow>
  86. <DropdownMenu
  87. triggerProps={{
  88. 'aria-label': t('Short-ID copy actions'),
  89. icon: <IconChevron direction="down" size="xs" />,
  90. size: 'zero',
  91. borderless: true,
  92. showChevron: false,
  93. }}
  94. position="bottom"
  95. size="xs"
  96. items={[
  97. {
  98. key: 'copy-url',
  99. label: t('Copy Feedback URL'),
  100. onAction: handleCopyUrl,
  101. },
  102. {
  103. key: 'copy-short-id',
  104. label: t('Copy Short-ID'),
  105. onAction: handleCopyShortId,
  106. },
  107. ]}
  108. />
  109. </Flex>
  110. </Flex>
  111. <Flex gap={space(1)} align="center" wrap="wrap">
  112. <ErrorBoundary mini>
  113. <FeedbackAssignedTo
  114. feedbackIssue={feedbackItem}
  115. feedbackEvent={eventData}
  116. />
  117. </ErrorBoundary>
  118. <ErrorBoundary mini>
  119. <Button
  120. onClick={() => {
  121. addLoadingMessage(t('Updating feedback...'));
  122. const newStatus =
  123. feedbackItem.status === 'resolved'
  124. ? GroupStatus.UNRESOLVED
  125. : GroupStatus.RESOLVED;
  126. resolve(newStatus, mutationOptions);
  127. }}
  128. >
  129. {feedbackItem.status === 'resolved' ? t('Unresolve') : t('Resolve')}
  130. </Button>
  131. </ErrorBoundary>
  132. <ErrorBoundary mini>
  133. <Button
  134. onClick={() => {
  135. addLoadingMessage(t('Updating feedback...'));
  136. markAsRead(!feedbackItem.hasSeen, mutationOptions);
  137. }}
  138. >
  139. {feedbackItem.hasSeen ? t('Mark Unread') : t('Mark Read')}
  140. </Button>
  141. </ErrorBoundary>
  142. </Flex>
  143. </Flex>
  144. {eventData && (
  145. <RowGapLinks>
  146. <ErrorBoundary mini>
  147. <IssueTrackingSection
  148. group={feedbackItem as unknown as Group}
  149. project={feedbackItem.project}
  150. event={eventData}
  151. />
  152. </ErrorBoundary>
  153. </RowGapLinks>
  154. )}
  155. </HeaderPanelItem>
  156. <OverflowPanelItem>
  157. <Section
  158. title={t('Description')}
  159. contentRight={
  160. <ErrorBoundary>
  161. <FeedbackViewers feedbackItem={feedbackItem} />
  162. </ErrorBoundary>
  163. }
  164. >
  165. <Blockquote>
  166. <pre>{feedbackItem.metadata.message}</pre>
  167. </Blockquote>
  168. </Section>
  169. <Section icon={<IconLink size="xs" />} title={t('URL')}>
  170. <ErrorBoundary mini>
  171. <TextCopyInput size="sm">
  172. {eventData?.tags ? (url ? url.value : t('URL not found')) : ''}
  173. </TextCopyInput>
  174. </ErrorBoundary>
  175. </Section>
  176. {crashReportId && (
  177. <CrashReportSection
  178. organization={organization}
  179. crashReportId={crashReportId}
  180. projSlug={feedbackItem.project.slug}
  181. />
  182. )}
  183. {hasReplayId && replayId && (
  184. <ReplaySection
  185. eventTimestampMs={new Date(feedbackItem.firstSeen).getTime()}
  186. organization={organization}
  187. replayId={replayId}
  188. />
  189. )}
  190. <TagsSection tags={tags} />
  191. </OverflowPanelItem>
  192. </Fragment>
  193. );
  194. }
  195. const HeaderPanelItem = styled(PanelItem)`
  196. display: grid;
  197. padding: ${space(1)} ${space(2)};
  198. gap: ${space(2)};
  199. `;
  200. const OverflowPanelItem = styled(PanelItem)`
  201. overflow: scroll;
  202. flex-direction: column;
  203. flex-grow: 1;
  204. gap: ${space(3)};
  205. `;
  206. const RowGapLinks = styled('div')`
  207. display: flex;
  208. align-items: flex-start;
  209. flex-wrap: wrap;
  210. column-gap: ${space(2)};
  211. `;
  212. const Blockquote = styled('blockquote')`
  213. margin: 0 ${space(4)};
  214. position: relative;
  215. &::before {
  216. position: absolute;
  217. color: ${p => p.theme.purple300};
  218. content: '❝';
  219. font-size: ${space(4)};
  220. left: -${space(4)};
  221. top: -0.4rem;
  222. }
  223. &::after {
  224. position: absolute;
  225. border: 1px solid ${p => p.theme.purple300};
  226. bottom: 0;
  227. content: '';
  228. left: -${space(1)};
  229. top: 0;
  230. }
  231. & > pre {
  232. margin: 0;
  233. background: none;
  234. font-family: inherit;
  235. font-size: ${p => p.theme.fontSizeMedium};
  236. line-height: 1.6;
  237. padding: 0;
  238. word-break: break-word;
  239. }
  240. `;